Files

1 line
20 KiB
JSON
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "alanxbitcoin/impetuschain", "files": {"src/impetus_chain/ai_gateway.py": {"e759a7ee026a35253b5373dacd00bd3b95f277d1": {"t": 1785746485.4688468, "n": 18655, "h": "db570c0cfbc1851c", "c": "import json\nimport os\nfrom pathlib import Path\nimport urllib.error\nimport urllib.parse\nimport urllib.request\nfrom dataclasses import dataclass\nfrom typing import Any, Callable\n\n\n@dataclass(frozen=True)\nclass ProviderSpec:\n provider_id: str\n label: str\n kind: str\n model: str\n api_key_env: str\n base_url: str = \"\"\n\n\nPROVIDERS: dict[str, ProviderSpec] = {\n \"openai\": ProviderSpec(\n provider_id=\"openai\",\n label=\"OpenAI GPT-5.5 (娣卞害鍒嗘瀽)\",\n kind=\"openai_responses\",\n model=\"gpt-5.5\",\n api_key_env=\"OPENAI_API_KEY\",\n base_url=\"https://api.openai.com/v1/responses\",\n ),\n \"openai_gpt54\": ProviderSpec(\n provider_id=\"openai_gpt54\",\n label=\"OpenAI GPT-5.4 (骞宠 閫熷害/璐ㄩ噺)\",\n kind=\"openai_responses\",\n model=\"gpt-5.4\",\n api_key_env=\"OPENAI_API_KEY\",\n base_url=\"https://api.openai.com/v1/responses\",\n ),\n \"openai_gpt54mini\": ProviderSpec(\n provider_id=\"openai_gpt54mini\",\n label=\"OpenAI GPT-5.4-mini (楂橀/浣庢垚鏈?\",\n kind=\"openai_responses\",\n model=\"gpt-5.4-mini\",\n api_key_env=\"OPENAI_API_KEY\",\n base_url=\"https://api.openai.com/v1/responses\",\n ),\n \"anthropic\": ProviderSpec(\n provider_id=\"anthropic\",\n label=\"Anthropic\",\n kind=\"anthropic_messages\",\n model=\"claude-3-5-sonnet-20241022\",\n api_key_env=\"ANTHROPIC_API_KEY\",\n base_url=\"https://api.anthropic.com/v1/messages\",\n ),\n \"gemini\": ProviderSpec(\n provider_id=\"gemini\",\n label=\"Google Gemini\",\n kind=\"gemini_generate_content\",\n model=\"gemini-1.5-pro\",\n api_key_env=\"GEMINI_API_KEY\",\n ),\n \"deepseek\": ProviderSpec(\n provider_id=\"deepseek\",\n label=\"DeepSeek\",\n kind=\"openai_compatible\",\n model=\"deepseek-chat\",\n api_key_env=\"DEEPSEEK_API_KEY\",\n base_url=\"https://api.deepseek.com/chat/completions\",\n ),\n \"qwen\": ProviderSpec(\n provider_id=\"qwen\",\n label=\"Qwen (DashScope)\",\n kind=\"openai_compatible\",\n model=\"qwen-plus\",\n api_key_env=\"DASHSCOPE_API_KEY\",\n base_url=\"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions\",\n ),\n}\n\n\nMODULE_DIR = Path(__file__).resolve().parent\nPROMPTS_DIR = MODULE_DIR / 'prompts'\nFULL_PROMPT_PATH = PROMPTS_DIR / 'node_analysis_full_prompt.txt'\nSTEP_PROMPT_PATH = PROMPTS_DIR / 'node_analysis_step_prompt.txt'\n\ndef provider_status() -> list[dict[str, Any]]:\n rows: list[dict[str, Any]] = []\n for provider in PROVIDERS.values():\n rows.append(\n {\n \"provider_id\": provider.provider_id,\n \"label\": provider.label,\n \"enabled\": bool(os.getenv(provider.api_key_env, \"\").strip()),\n \"api_key_env\": provider.api_key_env,\n }\n )\n return rows\n\n\ndef _mask_headers(headers: dict[str, str]) -> dict[str, str]:\n masked: dict[str, str] = {}\n for key, value in headers.items():\n if key.lower() in {\"authorization\", \"x-api-key\"}:\n masked[key] = \"***\"\n else:\n masked[key] = value\n return masked\n\n\ndef _http_post_json(\n url: str,\n payload: dict,\n headers: dict[str, str],\n progress: Callable[[str], None] | None = None,\n) -> dict:\n raw = json.dumps(payload).encode(\"utf-8\")\n if progress:\n progress(\n \"API request\\n\"\n f\"url: {url}\\n\"\n f\"headers: {json.dumps(_mask_headers(headers), ensure_ascii=False)}\\n\"\n f\"payload: {json.dumps(payload, ensure_ascii=False)}\"\n )\n req = urllib.request.Request(url=url, data=raw, method=\"POST\")\n req.add_header(\"Content-Type\", \"application/json\")\n for key, value in headers.items():\n req.add_header(key, value)\n try:\n with urllib.request.urlopen(req, timeout=60) as resp:\n body = resp.read().decode(\"utf-8\")\n if progress:\n progress(f\"API response\\nstatus: {getattr(resp, 'status', 'unknown')}\\nbody: {body}\")\n return json.loads(body)\n except urllib.error.HTTPError as exc:\n err_body = exc.read().decode(\"utf-8\", errors=\"replace\")\n if progress:\n progress(f\"API HTTP error {exc.code}\\nbody: {err_body}\")\n raise RuntimeError(f\"AI HTTP error {exc.code}: {err_body}\") from exc\n except urllib.error.URLError as exc:\n if progress:\n progress(f\"API network error: {exc.reason}\")\n raise RuntimeError(f\"AI network error: {exc.reason}\") from exc\n\n\ndef _read_prompt_template(path: Path, fallback: str) -> str:\n if path.exists():\n return path.read_text(encoding=\"utf-8\")\n return fallback\n\n\ndef _render_prompt(template: str, values: dict[str, str]) -> str:\n out = template\n for key, value in values.items():\n out = out.replace(key, value)\n return out\n\n\ndef _build_prompt(\n project: str,\n node: str,\n core_intro: str,\n strategy_text: str,\n prompt_script_text: str,\n) -> str:\n fallback = (\n \"You are a financial research assistant.\\n\\n\"\n \"Project Context:\\n\"\n \"- Project: __PROJECT__\\n\"\n \"- Selected Node: __NODE__\\n\"\n \"- Core Impetus Intro: __CORE_INTRO__\\n\\n\"\n \"You MUST follow these 5 analysis steps and output machine-readable keys first.\\n\\n\"\n \"Analysis Strategy File (authoritative project core):\\n\"\n \"__STRATEGY_TEXT__\\n\\n\"\n \"AI Prompt Script File (communication protocol):\\n\"\n \"__PROMPT_SCRIPT_TEXT__\\n\"\n )\n template = _read_prompt_template(FULL_PROMPT_PATH, fallback)\n return _render_prompt(\n template,\n {\n \"__PROJECT__\": project,\n \"__NODE__\": node,\n \"__CORE_INTRO__\": core_intro or \"N/A\",\n \"__STRATEGY_TEXT__\": strategy_text or \"[EMPTY]\",\n \"__PROMPT_SCRIPT_TEXT__\": prompt_script_text or \"[EMPTY]\",\n },\n )\n\n\ndef _build_step_prompt(\n project: str,\n node: str,\n core_intro: str,\n strategy_text: str,\n prompt_script_text: str,\n step_name: str,\n step_instruction: str,\n required_keys: list[str],\n) -> str:\n key_lines = \", \".join(required_keys)\n fallback = (\n \"You are a financial research assistant.\\n\\n\"\n \"Project Context:\\n\"\n \"- Project: __PROJECT__\\n\"\n \"- Selected Node: __NODE__\\n\"\n \"- Core Impetus Intro: __CORE_INTRO__\\n\\n\"\n \"Current Step: __STEP_NAME__\\n\"\n \"Instruction: __STEP_INSTRUCTION__\\n\\n\"\n \"Output must be STRICT JSON only (no markdown, no prose, no code fences).\\n\"\n \"Use exactly one JSON object with these keys:\\n\"\n \"__REQUIRED_KEYS__\\n\\n\"\n \"Analysis Strategy File (authoritative project core):\\n\"\n \"__STRATEGY_TEXT__\\n\\n\"\n \"AI Prompt Script File (communication protocol):\\n\"\n \"__PROMPT_SCRIPT_TEXT__\\n\\n\"\n \"If uncertain, still return valid JSON with best estimate and concise values.\"\n )\n template = _read_prompt_template(STEP_PROMPT_PATH, fallback)\n return _render_prompt(\n template,\n {\n \"__PROJECT__\": project,\n \"__NODE__\": node,\n \"__CORE_INTRO__\": core_intro or \"N/A\",\n \"__STEP_NAME__\": step_name,\n \"__STEP_INSTRUCTION__\": step_instruction,\n \"__REQUIRED_KEYS__\": key_lines,\n \"__STRATEGY_TEXT__\": strategy_text or \"[EMPTY]\",\n \"__PROMPT_SCRIPT_TEXT__\": prompt_script_text or \"[EMPTY]\",\n },\n )\n\n\ndef _extract_json_object(text: str) -> dict[str, Any]:\n text = text.strip()\n if not text:\n return {}\n try:\n parsed = json.loads(text)\n if isinstance(parsed, dict):\n return parsed\n except Exception:\n pass\n start = text.find(\"{\")\n end = text.rfind(\"}\")\n if start >= 0 and end > start:\n chunk = text[start : end + 1]\n try:\n parsed = json.loads(chunk)\n if isinstance(parsed, dict):\n return parsed\n except Exception:\n return {}\n return {}\n\n\ndef _normalize_text_value(value: Any, max_len: int = 240) -> str:\n text = str(value or \"\").strip()\n text = \" \".join(text.split())\n return text[:max_len]\n\n\ndef _normalize_impetus_type(value: Any) -> str:\n raw = _normalize_text_value(value, max_len=64).lower()\n allowed = {\n \"natural_disaster\",\n \"political_event\",\n \"social_event\",\n \"technology_progress\",\n }\n return raw if raw in allowed else \"social_event\"\n\n\ndef _normalize_list(value: Any, max_items: int = 8, max_len: int = 80) -> list[str]:\n if isinstance(value, list):\n items = [str(v).strip() for v in value]\n else:\n items = [s.strip() for s in str(value or \"\").split(\",\")]\n out: list[str] = []\n seen: set[str] = set()\n for item in items:\n if not item:\n continue\n norm = \" \".join(item.split())[:max_len]\n key = norm.casefold()\n if not norm or key in seen:\n continue\n seen.add(key)\n out.append(norm)\n if len(out) >= max_items:\n break\n return out\n\n\ndef _call_openai_compatible(\n spec: ProviderSpec, api_key: str, prompt: str, progress: Callable[[str], None] | None = None\n) -> str:\n payload = {\n \"model\": spec.model,\n \"messages\": [\n {\"role\": \"system\", \"content\": \"You are a concise financial analyst assistant.\"},\n {\"role\": \"user\", \"content\": prompt},\n ],\n \"temperature\": 0.2,\n }\n body = _http_post_json(\n url=spec.base_url,\n payload=payload,\n headers={\"Authorization\": f\"Bearer {api_key}\"},\n progress=progress,\n )\n return str(body[\"choices\"][0][\"message\"][\"content\"]).strip()\n\n\ndef _extract_openai_response_text(body: dict) -> str:\n output_text = str(body.get(\"output_text\", \"\")).strip()\n if output_text:\n return output_text\n chunks: list[str] = []\n for item in body.get(\"output\", []):\n for content in item.get(\"content\", []):\n text = str(content.get(\"text\", \"\")).strip()\n if text:\n chunks.append(text)\n return \"\\n\".join(chunks).strip()\n\n\ndef _call_openai_responses(\n spec: ProviderSpec, api_key: str, prompt: str, progress: Callable[[str], None] | None = None\n) -> str:\n payload = {\n \"model\": spec.model,\n \"reasoning\": {\"effort\": \"high\"},\n \"tools\": [{\"type\": \"web_search\"}],\n \"input\": prompt,\n }\n body = _http_post_json(\n url=spec.base_url,\n payload=payload,\n headers={\"Authorization\": f\"Bearer {api_key}\"},\n progress=progress,\n )\n return _extract_openai_response_text(body)\n\n\ndef _call_anthropic(\n spec: ProviderSpec, api_key: str, prompt: str, progress: Callable[[str], None] | None = None\n) -> str:\n payload = {\n \"model\": spec.model,\n \"max_tokens\": 1024,\n \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n }\n body = _http_post_json(\n url=spec.base_url,\n payload=payload,\n headers={\"x-api-key\": api_key, \"anthropic-version\": \"2023-06-01\"},\n progress=progress,\n )\n parts = body.get(\"content\", [])\n texts = [str(item.get(\"text\", \"\")) for item in parts if item.get(\"type\") == \"text\"]\n return \"\\n\".join(t for t in texts if t).strip()\n\n\ndef _call_gemini(\n spec: ProviderSpec, api_key: str, prompt: str, progress: Callable[[str], None] | None = None\n) -> str:\n encoded_model = urllib.parse.quote(spec.model, safe=\"\")\n key = urllib.parse.quote(api_key, safe=\"\")\n url = (\n \"https://generativelanguage.googleapis.com/v1beta/models/\"\n f\"{encoded_model}:generateContent?key={key}\"\n )\n payload = {\"contents\": [{\"parts\": [{\"text\": prompt}]}]}\n body = _http_post_json(url=url, payload=payload, headers={}, progress=progress)\n candidates = body.get(\"candidates\", [])\n if not candidates:\n return \"\"\n parts = candidates[0].get(\"content\", {}).get(\"parts\", [])\n texts = [str(item.get(\"text\", \"\")) for item in parts]\n return \"\\n\".join(t for t in texts if t).strip()\n\n\ndef analyze_node_with_ai(\n provider_id: str,\n project: str,\n node: str,\n core_intro: str = \"\",\n strategy_text: str = \"\",\n prompt_script_text: str = \"\",\n api_key_override: str = \"\",\n progress: Callable[[str], None] | None = None,\n) -> str:\n spec = PROVIDERS.get(provider_id)\n if spec is None:\n raise ValueError(f\"Unsupported AI provider: {provider_id}\")\n\n api_key = api_key_override.strip() or os.getenv(spec.api_key_env, \"\").strip()\n if not api_key:\n raise ValueError(f\"Missing API key: set env var {spec.api_key_env}\")\n\n prompt = _build_prompt(\n project=project,\n node=node,\n core_intro=core_intro,\n strategy_text=strategy_text,\n prompt_script_text=prompt_script_text,\n )\n if progress:\n progress(f\"Prompt\\n{prompt}\")\n if spec.kind == \"openai_responses\":\n return _call_openai_responses(spec=spec, api_key=api_key, prompt=prompt, progress=progress)\n if spec.kind == \"openai_compatible\":\n return _call_openai_compatible(spec=spec, api_key=api_key, prompt=prompt, progress=progress)\n if spec.kind == \"anthropic_messages\":\n return _call_anthropic(spec=spec, api_key=api_key, prompt=prompt, progress=progress)\n if spec.kind == \"gemini_generate_content\":\n return _call_gemini(spec=spec, api_key=api_key, prompt=prompt, progress=progress)\n raise ValueError(f\"Unsupported provider kind: {spec.kind}\")\n\n\ndef analyze_node_with_ai_five_steps(\n provider_id: str,\n project: str,\n node: str,\n core_intro: str = \"\",\n strategy_text: str = \"\",\n prompt_script_text: str = \"\",\n api_key_override: str = \"\",\n progress: Callable[[str], None] | None = None,\n) -> str:\n spec = PROVIDERS.get(provider_id)\n if spec is None:\n raise ValueError(f\"Unsupported AI provider: {provider_id}\")\n\n api_key = api_key_override.strip() or os.getenv(spec.api_key_env, \"\").strip()\n if not api_key:\n raise ValueError(f\"Missing API key: set env var {spec.api_key_env}\")\n\n steps = [\n (\n \"Step-1\",\n \"Classify impetus type from exactly one option: natural_disaster/political_event/social_event/technology_progress.\",\n [\"impetus_type\"],\n ),\n (\n \"Step-2\",\n \"Estimate expected duration and force evolution pattern.\",\n [\"duration_estimate\", \"force_pattern\"],\n ),\n (\n \"Step-3\",\n \"Estimate impact magnitude using global financial market cap change (USD range).\",\n [\"impact_market_cap_change_range_usd\"],\n ),\n (\n \"Step-4\",\n \"Identify directly tradable speculation targets (commodities/equities/bonds/currencies/indices).\",\n [\"direct_spec_targets\"],\n ),\n (\n \"Step-5\",\n \"Generate next-level expansion child nodes (3-8 short noun phrases) as JSON array.\",\n [\"next_level_nodes_json\"],\n ),\n ]\n\n structured: dict[str, Any] = {\n \"impetus_type\": \"social_event\",\n \"duration_estimate\": \"\",\n \"force_pattern\": \"\",\n \"impact_market_cap_change_range_usd\": \"\",\n \"direct_spec_targets\": [],\n \"next_level_nodes_json\": [],\n }\n\n for idx, (step_name, instruction, keys) in enumerate(steps, start=1):\n if progress:\n progress(f\"{step_name}: 发起第 {idx}/5 次 API 调用\")\n step_prompt = _build_step_prompt(\n project=project,\n node=node,\n core_intro=core_intro,\n strategy_text=strategy_text,\n prompt_script_text=prompt_script_text,\n step_name=step_name,\n step_instruction=instruction,\n required_keys=keys,\n )\n if progress:\n progress(f\"{step_name} Prompt\\n{step_prompt}\")\n if spec.kind == \"openai_responses\":\n step_text = _call_openai_responses(\n spec=spec, api_key=api_key, prompt=step_prompt, progress=progress\n )\n elif spec.kind == \"openai_compatible\":\n step_text = _call_openai_compatible(\n spec=spec, api_key=api_key, prompt=step_prompt, progress=progress\n )\n elif spec.kind == \"anthropic_messages\":\n step_text = _call_anthropic(\n spec=spec, api_key=api_key, prompt=step_prompt, progress=progress\n )\n elif spec.kind == \"gemini_generate_content\":\n step_text = _call_gemini(\n spec=spec, api_key=api_key, prompt=step_prompt, progress=progress\n )\n else:\n raise ValueError(f\"Unsupported provider kind: {spec.kind}\")\n\n if progress:\n progress(f\"{step_name}: 完成第 {idx}/5 次 API 调用\")\n data = _extract_json_object(step_text)\n if not data:\n if progress:\n progress(f\"{step_name}: 未解析到 JSON,将使用默认/空值\")\n data = {}\n\n if \"impetus_type\" in keys:\n structured[\"impetus_type\"] = _normalize_impetus_type(data.get(\"impetus_type\"))\n if \"duration_estimate\" in keys:\n structured[\"duration_estimate\"] = _normalize_text_value(\n data.get(\"duration_estimate\"), 120\n )\n if \"force_pattern\" in keys:\n structured[\"force_pattern\"] = _normalize_text_value(data.get(\"force_pattern\"), 120)\n if \"impact_market_cap_change_range_usd\" in keys:\n structured[\"impact_market_cap_change_range_usd\"] = _normalize_text_value(\n data.get(\"impact_market_cap_change_range_usd\"), 160\n )\n if \"direct_spec_targets\" in keys:\n structured[\"direct_spec_targets\"] = _normalize_list(\n data.get(\"direct_spec_targets\"), max_items=12, max_len=64\n )\n if \"next_level_nodes_json\" in keys:\n structured[\"next_level_nodes_json\"] = _normalize_list(\n data.get(\"next_level_nodes_json\"), max_items=8, max_len=80\n )\n\n return (\n f\"impetus_type: {structured['impetus_type']}\\n\"\n f\"duration_estimate: {structured['duration_estimate'] or 'unspecified'}\\n\"\n f\"force_pattern: {structured['force_pattern'] or 'unspecified'}\\n\"\n f\"impact_market_cap_change_range_usd: {structured['impact_market_cap_change_range_usd'] or 'unspecified'}\\n\"\n f\"direct_spec_targets: {', '.join(structured['direct_spec_targets']) or 'unspecified'}\\n\"\n f\"next_level_nodes_json: {json.dumps(structured['next_level_nodes_json'], ensure_ascii=False)}\\n\"\n f\"structured_json: {json.dumps(structured, ensure_ascii=False)}\"\n )\n\n\n"}}}}