소스 검색

Merge pull request #496 from meiguanxiHXX/feature/history-review-agent

[毕业设计] historyAgent - 多方历史辩证真伪
jjyaoao 2 달 전
부모
커밋
62764bf882
21개의 변경된 파일2243개의 추가작업 그리고 0개의 파일을 삭제
  1. 9 0
      Co-creation-projects/meiguanxiHXX-historyReviewAgent/.env.example
  2. 81 0
      Co-creation-projects/meiguanxiHXX-historyReviewAgent/README.md
  3. 69 0
      Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/README.md
  4. 6 0
      Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/__init__.py
  5. 42 0
      Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/cli_interactive.py
  6. 54 0
      Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/config.py
  7. 38 0
      Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/ddg_search.py
  8. 294 0
      Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/debate_orchestrator.py
  9. 131 0
      Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/debate_prompts.py
  10. 31 0
      Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/evidence_bundle.py
  11. 105 0
      Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/run_agent.py
  12. 1 0
      Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/web/__init__.py
  13. 165 0
      Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/web/app.py
  14. 23 0
      Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/web/cli.py
  15. 310 0
      Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/web/static/app.js
  16. 117 0
      Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/web/static/index.html
  17. 442 0
      Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/web/static/style.css
  18. 280 0
      Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/wiki_tools.py
  19. 30 0
      Co-creation-projects/meiguanxiHXX-historyReviewAgent/pyproject.toml
  20. 7 0
      Co-creation-projects/meiguanxiHXX-historyReviewAgent/requirements.txt
  21. 8 0
      Co-creation-projects/meiguanxiHXX-historyReviewAgent/run_web.py

+ 9 - 0
Co-creation-projects/meiguanxiHXX-historyReviewAgent/.env.example

@@ -0,0 +1,9 @@
+# OpenRouter(推荐):在 https://openrouter.ai/keys 创建密钥
+OPENROUTER_API_KEY=sk-or-v1-xxxxxxxx
+OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
+OPENROUTER_MODEL=openai/gpt-4o-mini
+
+# 也可改用教材通用变量(与 HelloAgentsLLM 的 custom 模式一致)
+# LLM_API_KEY=sk-or-v1-xxxxxxxx
+# LLM_BASE_URL=https://openrouter.ai/api/v1
+# LLM_MODEL_ID=openai/gpt-4o-mini

+ 81 - 0
Co-creation-projects/meiguanxiHXX-historyReviewAgent/README.md

@@ -0,0 +1,81 @@
+## 多角色历史辩论智能体(Historical Review Agent)
+
+立场预设:**官修史书不等于真相**;须联系**权力、文官书写、时代政治与语境**,对记载抱**怀疑目光**;野史笔记虽多不可靠,可与正史**对读缝隙**。本项目用 **五角色人设** + **终局综合模板** 落实上述取向;可选 **维基 + 检索** 作为考据附录(可关闭)。
+
+### 目录结构(核心)
+
+- `historical_review/`: Python 包(辩论编排、证据附录、交互 CLI)
+- `historical_review/web/`: FastAPI Web 与静态前端(`static/`)
+- `.env.example`: 环境变量示例
+
+### 安装
+
+建议使用虚拟环境,然后在本目录执行:
+
+```bash
+pip install -r requirements.txt
+pip install -e .
+```
+
+### 配置(OpenRouter / OpenAI 兼容)
+
+复制示例并填入 Key:
+
+```bash
+cp .env.example .env
+```
+
+常用变量:
+
+- `OPENROUTER_API_KEY`
+- `OPENROUTER_BASE_URL`(默认 `https://openrouter.ai/api/v1`)
+- `OPENROUTER_MODEL`(默认 `openai/gpt-4o-mini`)
+
+也支持通用变量:`LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL_ID`。
+
+### 命令行运行
+
+交互模式(会询问议题/是否启用附录/开始确认):
+
+```bash
+python -m historical_review.run_agent
+```
+
+非交互(适合脚本/自动化):
+
+```bash
+python -m historical_review.run_agent -y
+python -m historical_review.run_agent -y "你的历史议题"
+python -m historical_review.run_agent -y --no-evidence "你的议题"
+```
+
+安装后也可直接用脚本入口:
+
+```bash
+history-review -y "你的历史议题"
+```
+
+### Web 界面(推荐)
+
+启动:
+
+```bash
+python run_web.py
+```
+
+或安装后使用脚本入口:
+
+```bash
+history-review-web
+```
+
+浏览器打开 `http://127.0.0.1:8777`。
+
+- 页面可填写 Key/Base URL/模型/温度/超时/是否启用考据附录,并可保存到浏览器 `localStorage`
+- 服务端会读取本机 `.env`(页面 Key 留空时会使用环境变量)
+
+### 说明与免责声明
+
+- 输出为 **思辨与方法论训练**,不构成学术鉴定或考试标准答案
+- 终局综合不会给出“唯一真相”,而强调:官修的制度性偏差、野史可补之处、政治语境中的疑点、可采纳的谦逊判断、以及阴谋论 vs 正当怀疑的边界
+

+ 69 - 0
Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/README.md

@@ -0,0 +1,69 @@
+# 多角色历史辩论智能体
+
+立场预设:**官修史书不等于真相**;须联系**权力、文官书写、时代政治与语境**,对记载抱**怀疑目光**;野史笔记虽多不可靠,可与正史**对读缝隙**。通用模型作知识底座,本模块用 **五角色人设** + **终局综合模板** 落实上述取向;可选 **维基 + 检索** 作附录(可 `--no-evidence` 关闭)。
+
+## 角色(五方交锋)
+
+1. **官修史书与王朝叙事** — 复述常见官修框架,同时系统揭示其**政治文本**性质(胜利者、文官、避讳、曲笔),**禁止**把正史当免检真理。  
+2. **野史与边缘叙事** — 笔记、谣谚、小说家言等:低可信度但与正史对读可照出沉默与矛盾。  
+3. **政治语境与权力结构** — 谁掌权、谁修史、何种记载对当权「最省事」;何种记述显得**蹊跷**。  
+4. **域外与他者视角** — 外典亦带偏见,但有时能照见官修盲区。  
+5. **蹊跷与阴谋论辨析** — 区分地摊阴谋论与**史学上体面的怀疑**;「于谁有利、于谁消失」。
+
+流程:**第一轮** 五方各陈 → **秘书摘要** → **第二轮** 碰撞 → **终局综合**(不写「唯一真相」,而写:为何不可轻信官修、野史能补什么、政治下何处蹊跷、暂可采纳的谦逊判断、阴谋论 vs 正当怀疑)。  
+
+## 在 IDE 里消除 `from historical_review` 标红
+
+如果你是直接打开本项目目录,IDE 仍然标红 `from historical_review`,通常是因为没有做可编辑安装。任选其一即可:
+
+**方式 A(推荐)**:在项目根目录对当前虚拟环境做可编辑安装(与运行脚本用同一个解释器):
+
+```bash
+pip install -r requirements.txt
+pip install -e .
+```
+
+**方式 B(IntelliJ IDEA / PyCharm)**:在工程树中右键 **项目根目录** → **Mark Directory as** → **Sources Root**(标记为源代码根)。
+
+然后 **File → Invalidate Caches** 若仍标红,确认 **Project Interpreter** 选的是已安装依赖的同一环境。
+
+## Web 界面(推荐)
+
+深色「史观交锋」主题页,可在浏览器内配置 Key、Base URL、模型、温度、超时、是否启用考据附录,并可选将配置写入本机 `localStorage`。
+
+```bash
+pip install -r requirements.txt
+pip install -e .
+python run_web.py
+```
+
+浏览器打开 **http://127.0.0.1:8777** 。服务端会读取项目根目录 `.env`(可与页面填写的 Key 互补:页面留空则用环境变量)。
+
+等价命令:`uvicorn historical_review.web.app:app --reload --host 127.0.0.1 --port 8777`(需在项目根目录执行,保证能 `import historical_review`)。
+
+## 命令行运行
+
+```bash
+pip install -r requirements.txt
+pip install -e .
+cp .env.example .env   # OpenRouter 等
+```
+
+默认会**交互询问**:历史议题(可回车用示例)、是否抓取维基+检索附录、是否确认开始(约 **12** 次 LLM 调用)。
+
+```bash
+python -m historical_review.run_agent
+python -m historical_review.run_agent "你的历史议题"
+```
+
+非交互(脚本/CI,不再出现选择提示):
+
+```bash
+python -m historical_review.run_agent -y
+python -m historical_review.run_agent -y "你的议题"
+python -m historical_review.run_agent -y --no-evidence "你的议题"
+```
+
+## 说明
+
+输出为 **思辨与方法论训练**,非学术鉴定或考试标准答案;请勿将终局综合当作唯一真理表述。

+ 6 - 0
Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/__init__.py

@@ -0,0 +1,6 @@
+"""第十六章:多角色历史辩论 + 轻量网络附录 + 终局综合。"""
+
+from .config import create_llm
+from .debate_orchestrator import run_historical_debate
+
+__all__ = ["create_llm", "run_historical_debate"]

+ 42 - 0
Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/cli_interactive.py

@@ -0,0 +1,42 @@
+"""命令行交互:议题、是否网络附录、运行前确认。"""
+
+from __future__ import annotations
+
+import sys
+
+
+def prompt_yes_no(question: str, *, default: bool = True) -> bool:
+    """
+    询问 Y/n 或 y/N,回车采用 default。
+    在非 TTY 环境下直接返回 default,避免管道挂住。
+    """
+    if not sys.stdin.isatty():
+        return default
+
+    hint = " [Y/n] " if default else " [y/N] "
+    try:
+        raw = input(question + hint).strip().lower()
+    except EOFError:
+        return default
+
+    if raw == "":
+        return default
+    if raw in ("y", "yes", "是", "好", "确认", "1"):
+        return True
+    if raw in ("n", "no", "否", "不", "0"):
+        return False
+    return default
+
+
+def prompt_topic(default_topic: str) -> str:
+    if not sys.stdin.isatty():
+        return default_topic
+
+    try:
+        raw = input(
+            f"请输入历史议题(直接回车使用示例):\n> ",
+        ).strip()
+    except EOFError:
+        return default_topic
+
+    return raw if raw else default_topic

+ 54 - 0
Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/config.py

@@ -0,0 +1,54 @@
+"""LLM 配置:OpenRouter(OpenAI 兼容)。"""
+
+from __future__ import annotations
+
+import os
+
+from hello_agents import HelloAgentsLLM
+
+
+def create_llm(
+    *,
+    temperature: float = 0.4,
+    max_tokens: int | None = 4096,
+    api_key: str | None = None,
+    base_url: str | None = None,
+    model: str | None = None,
+    timeout: int | None = None,
+) -> HelloAgentsLLM:
+    """
+    OpenRouter:
+        OPENROUTER_API_KEY / OPENROUTER_BASE_URL / OPENROUTER_MODEL
+    或通用 LLM_* 变量。
+
+    传入的 api_key / base_url / model / timeout 优先于环境变量(供 Web 等场景覆盖)。
+    """
+    resolved_key = (
+        (api_key.strip() if api_key else None)
+        or os.getenv("OPENROUTER_API_KEY")
+        or os.getenv("LLM_API_KEY")
+    )
+    resolved_base = (
+        (base_url.strip() if base_url else None)
+        or os.getenv("OPENROUTER_BASE_URL")
+        or os.getenv("LLM_BASE_URL")
+        or "https://openrouter.ai/api/v1"
+    )
+    resolved_model = (
+        (model.strip() if model else None)
+        or os.getenv("OPENROUTER_MODEL")
+        or os.getenv("LLM_MODEL_ID")
+        or "openai/gpt-4o-mini"
+    )
+    kwargs: dict = {
+        "provider": "custom",
+        "api_key": resolved_key,
+        "base_url": resolved_base,
+        "model": resolved_model,
+        "temperature": temperature,
+        "max_tokens": max_tokens,
+    }
+    if timeout is not None:
+        kwargs["timeout"] = int(timeout)
+
+    return HelloAgentsLLM(**kwargs)

+ 38 - 0
Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/ddg_search.py

@@ -0,0 +1,38 @@
+"""仅用 DuckDuckGo 的轻量检索,避免 HelloAgents SearchTool 初始化时打印 Tavily/SerpAPI 提示。"""
+
+from __future__ import annotations
+
+
+def duckduckgo_search_text(
+    query: str,
+    *,
+    max_results: int = 4,
+    max_body_chars: int = 600,
+) -> str:
+    query = (query or "").strip()
+    if not query:
+        return "【检索】查询为空。"
+
+    try:
+        from ddgs import DDGS
+    except ImportError:
+        return "【检索】未安装 duckduckgo-search,请执行:pip install duckduckgo-search"
+
+    try:
+        with DDGS(timeout=15) as client:  # type: ignore[call-arg]
+            rows = client.text(query, max_results=max_results, backend="duckduckgo")
+    except Exception as e:  # pragma: no cover
+        return f"【检索】DuckDuckGo 请求失败:{e}"
+
+    if not rows:
+        return "【检索】无结果。"
+
+    lines: list[str] = ["【DuckDuckGo 检索摘要】"]
+    for i, entry in enumerate(rows, 1):
+        url = entry.get("href") or entry.get("url") or ""
+        title = entry.get("title") or url or "(无标题)"
+        body = entry.get("body") or entry.get("content") or ""
+        if len(body) > max_body_chars:
+            body = body[:max_body_chars] + "…"
+        lines.append(f"{i}. {title}\n   {url}\n   {body}")
+    return "\n\n".join(lines)

+ 294 - 0
Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/debate_orchestrator.py

@@ -0,0 +1,294 @@
+"""多角色历史辩论编排:观点碰撞 → 终局综合(最可能事实 / 可疑点 / 阴谋论辨析)。"""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Iterator
+from dataclasses import dataclass
+from typing import Any
+
+from hello_agents import HelloAgentsLLM
+
+from .config import create_llm
+from .debate_prompts import (
+    EVIDENCE_PREAMBLE,
+    SUMMARIZER_FOR_ROUND2,
+    SYSTEM_FOREIGN,
+    SYSTEM_OFFICIAL,
+    SYSTEM_POLITICAL,
+    SYSTEM_SUSPICION,
+    SYSTEM_SYNTHESIZER,
+    SYSTEM_UNOFFICIAL,
+    USER_ROUND1_TEMPLATE,
+    USER_ROUND2_TEMPLATE,
+    USER_SYNTHESIZER_TEMPLATE,
+)
+from .evidence_bundle import build_evidence_bundle
+
+
+@dataclass(frozen=True)
+class RoleSpec:
+    key: str
+    display_name: str
+    system_prompt: str
+
+
+ROLES: tuple[RoleSpec, ...] = (
+    RoleSpec("official", "官修史书与王朝叙事", SYSTEM_OFFICIAL),
+    RoleSpec("unofficial", "野史与边缘叙事", SYSTEM_UNOFFICIAL),
+    RoleSpec("political", "政治语境与权力结构", SYSTEM_POLITICAL),
+    RoleSpec("foreign", "域外与他者视角", SYSTEM_FOREIGN),
+    RoleSpec("suspicion", "蹊跷与阴谋论辨析", SYSTEM_SUSPICION),
+)
+
+# 进度:议题 + 附录 + 五角色第一轮 + 秘书 + 五角色第二轮 + 终局(step 0..14 → 共 15 段)
+TOTAL_STEPS = 15
+
+
+def _excerpt(text: str, limit: int = 380) -> str:
+    text = (text or "").strip()
+    if len(text) <= limit:
+        return text
+    return text[:limit] + "…"
+
+
+def _invoke(llm: HelloAgentsLLM, system: str, user: str, *, temperature: float) -> str:
+    messages = [
+        {"role": "system", "content": system},
+        {"role": "user", "content": user},
+    ]
+    return (llm.invoke(messages, temperature=temperature) or "").strip()
+
+
+def _summarize_round1_for_context(llm: HelloAgentsLLM, round1: dict[str, str]) -> str:
+    body = "\n\n".join(f"### {r.display_name}\n{round1[r.key]}" for r in ROLES)
+    return _invoke(
+        llm,
+        SUMMARIZER_FOR_ROUND2,
+        body,
+        temperature=0.15,
+    )
+
+
+def _yield_progress(step: int, message: str, **extra: Any) -> dict[str, Any]:
+    return {
+        "event": "progress",
+        "step": step,
+        "total": TOTAL_STEPS,
+        "message": message,
+        **extra,
+    }
+
+
+def iter_debate_events(
+    topic: str,
+    *,
+    llm: HelloAgentsLLM | None = None,
+    use_evidence_bundle: bool = True,
+    debate_temperature: float = 0.72,
+    synthesizer_temperature: float = 0.22,
+    llm_api_key: str | None = None,
+    llm_base_url: str | None = None,
+    llm_model: str | None = None,
+    llm_max_tokens: int | None = 4096,
+    llm_timeout: int | None = None,
+) -> Iterator[dict[str, Any]]:
+    """
+    逐步产出辩论过程事件,供 SSE / 日志展示。
+
+    事件类型
+    --------
+    - progress: step, total, message
+    - round1_start / round1_end: role, content(end)
+    - digest_start / digest_end: content(end)
+    - round2_start / round2_end: role, content(end)
+    - synthesis_start / synthesis_end: content(end)
+    - complete: markdown(全文)
+    """
+    topic = (topic or "").strip()
+    if not topic:
+        raise ValueError("议题不能为空")
+
+    if llm is None:
+        llm = create_llm(
+            api_key=llm_api_key,
+            base_url=llm_base_url,
+            model=llm_model,
+            max_tokens=llm_max_tokens,
+            timeout=llm_timeout,
+            temperature=0.4,
+        )
+
+    step = 0
+
+    yield _yield_progress(step, f"议题已接收:{topic[:80]}{'…' if len(topic) > 80 else ''}")
+    step += 1
+
+    evidence_block = ""
+    if use_evidence_bundle:
+        yield _yield_progress(step, "正在抓取维基与 DuckDuckGo 考据附录(可能需几十秒)…")
+        evidence_block = EVIDENCE_PREAMBLE + "\n\n" + build_evidence_bundle(topic)
+        yield {
+            "event": "evidence_done",
+            "step": step,
+            "total": TOTAL_STEPS,
+            "chars": len(evidence_block),
+            "preview": evidence_block[:600] + ("…" if len(evidence_block) > 600 else ""),
+        }
+    else:
+        yield _yield_progress(step, "已跳过网络附录,将仅依赖模型知识。")
+        evidence_block = "(未启用网络附录;请完全依赖你的训练知识与逻辑。)"
+    step += 1
+
+    lines: list[str] = [
+        "# 多角色历史辩论记录\n",
+        f"## 议题\n{topic}\n",
+    ]
+
+    round1: dict[str, str] = {}
+    for role in ROLES:
+        yield {
+            "event": "round1_start",
+            "step": step,
+            "total": TOTAL_STEPS,
+            "role": role.display_name,
+            "message": f"第一轮 · {role.display_name}:正在调用模型…",
+        }
+        user_msg = USER_ROUND1_TEMPLATE.format(topic=topic, evidence_block=evidence_block)
+        out = _invoke(llm, role.system_prompt, user_msg, temperature=debate_temperature)
+        round1[role.key] = out
+        md_chunk = f"### 第一轮 · {role.display_name}\n\n{out}\n"
+        lines.append(md_chunk)
+        yield {
+            "event": "round1_end",
+            "step": step,
+            "total": TOTAL_STEPS,
+            "role": role.display_name,
+            "content": out,
+            "markdown_section": md_chunk,
+        }
+        step += 1
+
+    yield {
+        "event": "digest_start",
+        "step": step,
+        "total": TOTAL_STEPS,
+        "message": "秘书:正在压缩第一轮五角色发言…",
+    }
+    digest = _summarize_round1_for_context(llm, round1)
+    digest_md = f"### 秘书摘要(供第二轮引用)\n\n{digest}\n"
+    lines.append(digest_md)
+    yield {
+        "event": "digest_end",
+        "step": step,
+        "total": TOTAL_STEPS,
+        "content": digest,
+        "markdown_section": digest_md,
+    }
+    step += 1
+
+    round2: dict[str, str] = {}
+    for role in ROLES:
+        yield {
+            "event": "round2_start",
+            "step": step,
+            "total": TOTAL_STEPS,
+            "role": role.display_name,
+            "message": f"第二轮观点碰撞 · {role.display_name}:正在调用模型…",
+        }
+        peer_bits = "\n".join(
+            f"- **{r.display_name}**(摘录):{_excerpt(round1[r.key], 420)}"
+            for r in ROLES
+            if r.key != role.key
+        )
+        user_msg = USER_ROUND2_TEMPLATE.format(
+            topic=topic,
+            other_summaries=digest + "\n\n**他角色第一轮摘录(供点名反驳)**:\n" + peer_bits,
+            self_previous=_excerpt(round1[role.key], 520),
+        )
+        out = _invoke(llm, role.system_prompt, user_msg, temperature=debate_temperature)
+        round2[role.key] = out
+        md_chunk = f"### 第二轮 · 观点碰撞 · {role.display_name}\n\n{out}\n"
+        lines.append(md_chunk)
+        yield {
+            "event": "round2_end",
+            "step": step,
+            "total": TOTAL_STEPS,
+            "role": role.display_name,
+            "content": out,
+            "markdown_section": md_chunk,
+        }
+        step += 1
+
+    yield {
+        "event": "synthesis_start",
+        "step": step,
+        "total": TOTAL_STEPS,
+        "message": "终局综合:正在生成「最可能事实 / 可疑点 / 阴谋论辨析」…",
+    }
+    full_transcript = "\n".join(lines)
+    final_user = USER_SYNTHESIZER_TEMPLATE.format(topic=topic, full_transcript=full_transcript)
+    verdict = _invoke(llm, SYSTEM_SYNTHESIZER, final_user, temperature=synthesizer_temperature)
+    tail = "---\n\n# 终局综合\n\n" + verdict
+    lines.append("---\n")
+    lines.append("# 终局综合\n")
+    lines.append(verdict)
+    full_md = "\n".join(lines)
+
+    yield {
+        "event": "synthesis_end",
+        "step": step,
+        "total": TOTAL_STEPS,
+        "content": verdict,
+        "markdown_section": tail,
+    }
+    step += 1
+
+    yield {
+        "event": "complete",
+        "step": step,
+        "total": TOTAL_STEPS,
+        "markdown": full_md,
+        "message": "全部完成",
+    }
+
+
+def run_historical_debate(
+    topic: str,
+    *,
+    llm: HelloAgentsLLM | None = None,
+    use_evidence_bundle: bool = True,
+    debate_temperature: float = 0.72,
+    synthesizer_temperature: float = 0.22,
+    llm_api_key: str | None = None,
+    llm_base_url: str | None = None,
+    llm_model: str | None = None,
+    llm_max_tokens: int | None = 4096,
+    llm_timeout: int | None = None,
+) -> str:
+    """执行两轮角色辩论 + 终局综合报告(无流式,供 CLI 等)。"""
+    last: dict[str, Any] | None = None
+    for ev in iter_debate_events(
+        topic,
+        llm=llm,
+        use_evidence_bundle=use_evidence_bundle,
+        debate_temperature=debate_temperature,
+        synthesizer_temperature=synthesizer_temperature,
+        llm_api_key=llm_api_key,
+        llm_base_url=llm_base_url,
+        llm_model=llm_model,
+        llm_max_tokens=llm_max_tokens,
+        llm_timeout=llm_timeout,
+    ):
+        last = ev
+    if not last or last.get("event") != "complete":
+        raise RuntimeError("辩论未正常结束")
+    md = last.get("markdown")
+    if not isinstance(md, str):
+        raise RuntimeError("缺少完整 Markdown")
+    return md
+
+
+def debate_event_json(ev: dict[str, Any]) -> str:
+    """序列化单条事件(SSE data 行)。"""
+    return json.dumps(ev, ensure_ascii=False)

+ 131 - 0
Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/debate_prompts.py

@@ -0,0 +1,131 @@
+"""多角色历史辩论:人设、输出格式与终局综合模板。
+
+核心立场:史料(含正史)不等于真相;须联系权力、文官书写、时代语境与边缘材料,抱怀疑目光审读。
+"""
+
+# 开场共用:附在每条用户消息前的「材料包」说明
+EVIDENCE_PREAMBLE = """以下段落是自动抓取的公开网页摘要(维基、检索等),**二手且可能偏正典叙述**,不可当证据链终点。
+你的任务不是「复述网页」,而是结合训练知识,把材料当作**对读线索**;若需野史、笔记、域外记载,应在发言中主动指出「材料缺口」并说明应去何处寻何种类型史料。"""
+
+# 原「正史派」改为:只负责**呈现官修叙事长什么样**,并系统性质疑其可靠性
+SYSTEM_OFFICIAL = """你是「官修史书与王朝叙事」代言人(角色扮演)。
+
+**你必须首先承认**:二十四史、实录、敕撰文献是**政治文本**,不是中立真相记录。历史多由胜利者、在朝文官、承旨史臣书写;避讳、曲笔、删削、道德化定调是常态。
+
+要求(约 520 字内):
+1. 简述该议题在**常见官修/教科书框架**里通常怎么写(时间线与定调即可)。
+2. **逐项说明**:这类写法可能服务了何种**政治合法性**(正统、华夷、君臣、朋党、边患、嗣统等)。
+3. 指出至少 **2~3 处**「仅凭官修文本无法自证」或「明显利于当权者」的叙述环节——**不得**把官修记载直接当作已证实事实输出。
+4. 结尾用一两句钉死:**复述正史不等于采信正史**。"""
+
+SYSTEM_UNOFFICIAL = """你是「野史、笔记、小说家言、谣谚与边缘叙事」代言人(角色扮演)。
+
+立场:野史多误传、多夸张,但常与官修**对读**时暴露「正史沉默的缝隙」、民间记忆与被压制的声音。
+要求(约 520 字内):
+1. 列举与本议题可能相关的**野史/笔记/类书/传说**类型材料(不必捏造书名卷次;可写「常见会提到的……一类记载」并标**可信度低**)。
+2. 说明野史为何**不能**当铁证,又为何**仍值得**作为怀疑与提问的起点。
+3. 给出 2~4 个「若野史与正史在此处打架,应优先核查什么(考古、碑刻、私人文集、域外志、不同时点编纂差异)」。
+4. 与「官修叙事」角色**正面交锋**:指出若只读正史会漏掉哪类动机与哪类当事人。"""
+
+SYSTEM_POLITICAL = """你是「政治语境与权力结构」代言人(角色扮演)。
+
+立场:**离开当时谁掌权、谁修史、何种意识形态在斗争,就无法判断一条记载是否「蹊跷」**。
+要求(约 520 字内):
+1. 勾勒该事件/人物所处时代的**权力格局**(宫廷、藩镇、外戚、宦官、文官集团、民族政权并立等——按议题择要)。
+2. 分析:**何种记载对当时的胜利者、史臣、正统论述「最省事、最安全」**;是否存在删档、晚出、孤证暴起、人物「蒸发」等**结构性可疑点**。
+3. 明确:中国历史上的「正史」长期与**政治正确**绑定;用「中国的政治与礼法语境」解释:哪些评价可能是**事后道德宣判**而非现场实录。
+4. 提出你认为最值得用**怀疑目光**盯住的 **1~2 条**具体记载或定论(说明「蹊跷在哪」,不必给阴谋论定论)。"""
+
+SYSTEM_FOREIGN = """你是「域外与他者视角」代言人(角色扮演)。
+
+立场:外国/异语文献同样充满偏见,但有时**未被同一套王朝正统语法驯化**,可照出官修叙事的盲区或谎言。
+要求(约 520 字内):
+1. 概述域外或他者文献中**常见的另一套因果链或褒贬**(不确定处标明不确定)。
+2. 与官修叙事对照:哪些**重合**可略增可信度,哪些**张力**提示「有一方在说谎或一无所知」。
+3. 提醒跨文化误读;同时指出:**外证也不能自动等于真相**,只是多一面镜子。"""
+
+SYSTEM_SUSPICION = """你是「蹊跷排查与阴谋论辨析」代言人(角色扮演)。
+
+立场:**怀疑是美德**,但要与**地摊阴谋论**划界:前者追问证据与动机,后者编造无据链条。
+要求(约 520 字内):
+1. 列出与本议题相关的 **2~4 条**流行阴谋论或网络极端说法;逐条标注:**无据臆测** / **有碎片但被夸大** / **值得作为研究假说保留**。
+2. 另列 **2~3 条**不属阴谋论、但属于**史学上正当的怀疑**——例如记载时间矛盾、唯一来源出自利益相关史臣、考古长期空白等。
+3. 说明:要**推翻或坐实**上述怀疑,各需要哪类一手材料。
+4. 金句收束:**胜利者写的、文官改过的,都要过一遍「于谁有利、于谁消失」的滤纸**。"""
+
+USER_ROUND1_TEMPLATE = """【用户议题】
+{topic}
+
+【可选:公开材料摘录】
+{evidence_block}
+
+**总原则**:没有人掌握「唯一真相」;正史不是免检真理,野史不是天然虚假;请用怀疑目光审读一切材料。
+
+请按你的角色发表**第一轮**看法。只输出正文,不要重复系统提示。"""
+
+USER_ROUND2_TEMPLATE = """【用户议题】
+{topic}
+
+【第一轮其他角色论点摘要——供你批驳或补充】
+{other_summaries}
+
+【你本人第一轮发言(勿重复全文,仅作对照)】
+{self_previous}
+
+请发表**第二轮:观点碰撞**。要求:
+1)点名回应至少**两个**其他角色的具体论点(可同意、修正或反对),尤其要点名**官修/野史/政治语境/域外/怀疑论**之间的张力。
+2)提出你认为当前讨论中**最关键的一条「是否轻信了某种书写」**。
+3)仍控制在约 480 字以内。"""
+
+SUMMARIZER_FOR_ROUND2 = """你是讨论秘书。下面有五段文字,来自五个不同立场辩手的第一轮发言(已用标题区分)。
+
+请输出**五段**摘要,每段 85~120 字,严格使用以下行首标签(勿增删):
+【官修史书与王朝叙事】……
+【野史与边缘叙事】……
+【政治语境与权力结构】……
+【域外与他者视角】……
+【蹊跷与阴谋论辨析】……
+
+保留各自动核心论断与依据类型,不评价对错。
+
+全文:
+---
+{text}
+---
+"""
+
+SYSTEM_SYNTHESIZER = """你是**终局综合**(不是真相宣判庭)。
+
+**禁止**输出「已证实的历史真相」式口吻。中国历史上的核心记载多经**胜利者与文官体系**筛选;你的任务是呈现**在怀疑之后仍较站得住脚的临时判断**与**必须继续悬置之处**。
+
+输入:五角色辩论记录。
+
+输出必须使用以下 Markdown 结构(二级标题用 ##):
+
+## 1. 为何不能轻信官修与「定论」
+- 用条目说明:胜利者书写、文官笔墨、政治合法性、讳饰与删削如何**系统性地**影响该议题的常见叙述(不写空话,扣议题)。
+
+## 2. 野史、边缘材料与「缝隙」
+- 野史笔记能补什么、不能证什么;与正史对读时暴露出哪些**沉默或矛盾**。
+
+## 3. 政治语境下:哪些记载显得「蹊跷」或特别「顺滑」
+- 结合当时权力结构,指出 **2~5 处**值得长期怀疑的记述或定论(说明「可疑类型」:孤证、晚出、利益相关 sole source、考古空白等),**不等于**断言造假。
+
+## 4. 暂可采纳的工作性判断(非永恒真理)
+- 3~6 条要点;每条括号内标注**置信度(高/中/低)**与**理由**(多重独立来源 / 仅官修 / 仅有考古反证缺口等)。措辞须谦逊。
+
+## 5. 阴谋论 vs 正当怀疑
+- 分表或分条:荒诞说法如何剔除;哪些怀疑在方法论上仍**体面**。
+
+## 6. 结语
+- 强调:**史料均可能错;真相不可独占**;读者应持续对一切书写——包括本综合——保持怀疑与补证意识。
+
+约束:禁止捏造具体引文、页码、档案编号;信息不足写「不确定」。"""
+
+USER_SYNTHESIZER_TEMPLATE = """【用户议题】
+{topic}
+
+【完整辩论记录】
+{full_transcript}
+
+请严格按照系统说明的**六个**二级标题输出终局综合(简体中文)。"""

+ 31 - 0
Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/evidence_bundle.py

@@ -0,0 +1,31 @@
+"""可选:从公开网络拉取简报,作为辩论的「考据附录」(模型仍以自身知识为主)。"""
+
+from __future__ import annotations
+
+from .ddg_search import duckduckgo_search_text
+from .wiki_tools import wiki_multiview
+
+
+def build_evidence_bundle(topic: str, *, max_chars: int = 5500) -> str:
+    """
+    聚合维基多语种摘录 + 少量检索结果,失败时返回说明性短文本。
+    """
+    chunks: list[str] = []
+    try:
+        w = wiki_multiview(topic.strip())
+        if len(w) > 4000:
+            w = w[:4000] + "\n... [维基部分已截断]"
+        chunks.append("【维基多语种摘录】\n" + w)
+    except Exception as e:  # pragma: no cover
+        chunks.append(f"【维基】抓取失败:{e}")
+
+    try:
+        q = f"{topic.strip()} 历史 笔记 野史 争议 研究"
+        chunks.append(duckduckgo_search_text(q, max_results=4, max_body_chars=600))
+    except Exception as e:  # pragma: no cover
+        chunks.append(f"【检索】失败:{e}")
+
+    text = "\n\n---\n\n".join(chunks)
+    if len(text) > max_chars:
+        text = text[:max_chars] + "\n... [总附录已截断]"
+    return text

+ 105 - 0
Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/run_agent.py

@@ -0,0 +1,105 @@
+#!/usr/bin/env python3
+"""
+多角色历史辩论:观点碰撞 → 终局综合。
+
+默认**交互**:会询问议题、是否网络附录、是否确认开始。
+一键非交互:加 -y(或 --yes),可配合命令行议题。
+"""
+
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+from historical_review.cli_interactive import prompt_topic, prompt_yes_no
+from historical_review.debate_orchestrator import run_historical_debate
+
+DEFAULT_TOPIC = (
+    "玄武门之变:官修叙事如何书写?若结合当时宫廷权力与文官修史环境,"
+    "有哪些记载显得蹊跷?野史与域外材料能否照出缝隙?"
+)
+
+
+def main() -> None:
+    project_root = Path(__file__).resolve().parents[1]
+    load_dotenv(project_root / ".env")
+    load_dotenv()
+
+    parser = argparse.ArgumentParser(
+        description="多角色历史辩论(官修/野史/政治语境/域外/蹊跷辨析 → 综合)",
+    )
+    parser.add_argument(
+        "topic",
+        nargs="?",
+        default=None,
+        help="历史议题;省略则在交互模式下询问",
+    )
+    parser.add_argument(
+        "-y",
+        "--yes",
+        action="store_true",
+        help="非交互:不询问,直接使用参数与默认值(适合脚本/自动化)",
+    )
+    parser.add_argument(
+        "--no-evidence",
+        action="store_true",
+        help="不抓取维基/检索附录(若在交互模式且未指定本项,仍会询问是否启用)",
+    )
+    parser.add_argument(
+        "--debate-temp",
+        type=float,
+        default=0.72,
+        help="辩论轮温度",
+    )
+    parser.add_argument(
+        "--synth-temp",
+        type=float,
+        default=0.22,
+        help="终局综合温度",
+    )
+    args = parser.parse_args()
+
+    topic = args.topic
+    use_evidence = not args.no_evidence
+
+    if args.yes:
+        if topic is None or not str(topic).strip():
+            topic = DEFAULT_TOPIC
+        use_evidence = not args.no_evidence
+    else:
+        print("=" * 56)
+        print(" 多角色历史辩论 — 请先确认选项(加 -y 可跳过本流程)")
+        print("=" * 56)
+        if topic is None or not str(topic).strip():
+            topic = prompt_topic(DEFAULT_TOPIC)
+        else:
+            print(f"\n议题(来自命令行):{topic}\n")
+
+        if args.no_evidence:
+            use_evidence = False
+            print("已指定 --no-evidence:不抓取维基/检索附录。\n")
+        else:
+            use_evidence = prompt_yes_no("是否启用维基与检索作为考据附录?", default=True)
+            print()
+
+        if not prompt_yes_no(
+            "即将依次调用:5 角色第一轮 + 1 次秘书摘要 + 5 角色第二轮 + 1 次终局综合(约 12 次 LLM 请求),确认开始?",
+            default=True,
+        ):
+            print("已取消。")
+            sys.exit(0)
+        print()
+
+    report = run_historical_debate(
+        str(topic).strip(),
+        use_evidence_bundle=use_evidence,
+        debate_temperature=args.debate_temp,
+        synthesizer_temperature=args.synth_temp,
+    )
+    print(report)
+
+
+if __name__ == "__main__":
+    main()

+ 1 - 0
Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/web/__init__.py

@@ -0,0 +1 @@
+"""Web UI:FastAPI + 静态前端。"""

+ 165 - 0
Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/web/app.py

@@ -0,0 +1,165 @@
+"""史观交锋 Web:提供静态页与辩论 API。"""
+
+from __future__ import annotations
+
+import asyncio
+import concurrent.futures
+import os
+from pathlib import Path
+
+from dotenv import load_dotenv
+from fastapi import FastAPI, HTTPException
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import FileResponse, StreamingResponse
+from fastapi.staticfiles import StaticFiles
+from pydantic import BaseModel, Field
+
+from historical_review.debate_orchestrator import iter_debate_events, run_historical_debate
+
+_STATIC = Path(__file__).resolve().parent / "static"
+_PROJECT_ROOT = Path(__file__).resolve().parents[2]
+load_dotenv(_PROJECT_ROOT / ".env")
+load_dotenv()
+
+app = FastAPI(title="史观交锋", description="多角色历史辩论:官修/野史/政治语境/域外/蹊跷辨析")
+
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["*"],
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+if _STATIC.is_dir():
+    app.mount("/static", StaticFiles(directory=str(_STATIC)), name="static")
+
+
+class DebateRequest(BaseModel):
+    topic: str = Field(..., min_length=1, max_length=8000)
+    api_key: str | None = Field(None, description="OpenRouter 等 API Key,可空则用服务端环境变量")
+    base_url: str | None = Field(None, description="OpenAI 兼容 Base URL")
+    model: str | None = Field(None, description="模型 ID,如 openai/gpt-4o-mini")
+    max_tokens: int | None = Field(4096, ge=256, le=128000)
+    timeout: int | None = Field(180, ge=30, le=600, description="单次 HTTP 请求超时秒数")
+    debate_temperature: float = Field(0.72, ge=0.0, le=2.0)
+    synthesizer_temperature: float = Field(0.22, ge=0.0, le=2.0)
+    use_evidence_bundle: bool = True
+
+
+class DebateResponse(BaseModel):
+    ok: bool
+    markdown: str | None = None
+    error: str | None = None
+
+
+def _api_key_error(req: DebateRequest) -> str | None:
+    has_key = bool(req.api_key and req.api_key.strip())
+    if not has_key and not (os.getenv("OPENROUTER_API_KEY") or os.getenv("LLM_API_KEY")):
+        return "未配置 API Key:请在左侧填写 OpenRouter Key,或在服务器 .env 中设置 OPENROUTER_API_KEY。"
+    return None
+
+
+@app.get("/")
+async def index_page() -> FileResponse:
+    html = _STATIC / "index.html"
+    if not html.is_file():
+        raise HTTPException(status_code=500, detail="前端文件缺失,请检查 historical_review/web/static/")
+    return FileResponse(html)
+
+
+@app.get("/api/health")
+async def health() -> dict[str, str]:
+    return {"status": "ok"}
+
+
+@app.post("/api/debate", response_model=DebateResponse)
+async def run_debate(req: DebateRequest) -> DebateResponse:
+    topic = req.topic.strip()
+    if not topic:
+        raise HTTPException(status_code=400, detail="议题不能为空")
+
+    key_err = _api_key_error(req)
+    if key_err:
+        return DebateResponse(ok=False, error=key_err)
+
+    def _work() -> str:
+        return run_historical_debate(
+            topic,
+            use_evidence_bundle=req.use_evidence_bundle,
+            debate_temperature=req.debate_temperature,
+            synthesizer_temperature=req.synthesizer_temperature,
+            llm_api_key=req.api_key.strip() if req.api_key else None,
+            llm_base_url=req.base_url.strip() if req.base_url else None,
+            llm_model=req.model.strip() if req.model else None,
+            llm_max_tokens=req.max_tokens,
+            llm_timeout=req.timeout,
+        )
+
+    loop = asyncio.get_event_loop()
+    try:
+        with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
+            md = await asyncio.wait_for(
+                loop.run_in_executor(pool, _work),
+                timeout=900.0,
+            )
+    except asyncio.TimeoutError:
+        return DebateResponse(ok=False, error="任务超时(>15 分钟),请换小议题或提高超时/换更快模型。")
+    except Exception as e:  # pragma: no cover
+        return DebateResponse(ok=False, error=f"{type(e).__name__}: {e}")
+
+    return DebateResponse(ok=True, markdown=md)
+
+
+@app.post("/api/debate/stream")
+def debate_stream(req: DebateRequest) -> StreamingResponse:
+    """SSE:逐段推送辩论进度与各角色发言,最后 complete 带全文 Markdown。"""
+    topic = req.topic.strip()
+    if not topic:
+
+        def err_only():
+            import json
+
+            yield f"data: {json.dumps({'event': 'error', 'message': '议题不能为空'}, ensure_ascii=False)}\n\n".encode(
+                "utf-8"
+            )
+
+        return StreamingResponse(err_only(), media_type="text/event-stream")
+
+    key_err = _api_key_error(req)
+    if key_err:
+
+        def err_key():
+            import json
+
+            yield f"data: {json.dumps({'event': 'error', 'message': key_err}, ensure_ascii=False)}\n\n".encode("utf-8")
+
+        return StreamingResponse(err_key(), media_type="text/event-stream")
+
+    def event_bytes():
+        import json
+
+        try:
+            for ev in iter_debate_events(
+                topic,
+                use_evidence_bundle=req.use_evidence_bundle,
+                debate_temperature=req.debate_temperature,
+                synthesizer_temperature=req.synthesizer_temperature,
+                llm_api_key=req.api_key.strip() if req.api_key else None,
+                llm_base_url=req.base_url.strip() if req.base_url else None,
+                llm_model=req.model.strip() if req.model else None,
+                llm_max_tokens=req.max_tokens,
+                llm_timeout=req.timeout,
+            ):
+                line = f"data: {json.dumps(ev, ensure_ascii=False)}\n\n"
+                yield line.encode("utf-8")
+        except Exception as e:  # pragma: no cover
+            err_ev = {"event": "error", "message": f"{type(e).__name__}: {e}"}
+            yield f"data: {json.dumps(err_ev, ensure_ascii=False)}\n\n".encode("utf-8")
+
+    headers = {
+        "Cache-Control": "no-cache",
+        "Connection": "keep-alive",
+        "X-Accel-Buffering": "no",
+    }
+    return StreamingResponse(event_bytes(), media_type="text/event-stream", headers=headers)

+ 23 - 0
Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/web/cli.py

@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+import os
+
+import uvicorn
+
+
+def main() -> None:
+    host = os.getenv("HISTORY_REVIEW_HOST", "127.0.0.1")
+    port = int(os.getenv("HISTORY_REVIEW_PORT", "8777"))
+    reload = os.getenv("HISTORY_REVIEW_RELOAD", "1").strip() not in {"0", "false", "False"}
+
+    uvicorn.run(
+        "historical_review.web.app:app",
+        host=host,
+        port=port,
+        reload=reload,
+    )
+
+
+if __name__ == "__main__":
+    main()
+

+ 310 - 0
Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/web/static/app.js

@@ -0,0 +1,310 @@
+(function () {
+  const STORAGE_KEY = "historical_review_web_v1";
+
+  const $ = (id) => document.getElementById(id);
+
+  function readForm() {
+    return {
+      api_key: $("cfg-api-key").value.trim(),
+      base_url: $("cfg-base-url").value.trim(),
+      model: $("cfg-model").value.trim(),
+      debate_temperature: parseFloat($("cfg-debate-temp").value) || 0.72,
+      synthesizer_temperature: parseFloat($("cfg-synth-temp").value) || 0.22,
+      max_tokens: parseInt($("cfg-max-tokens").value, 10) || 4096,
+      timeout: parseInt($("cfg-timeout").value, 10) || 180,
+      use_evidence_bundle: $("cfg-use-evidence").checked,
+    };
+  }
+
+  function applyForm(data) {
+    if (!data) return;
+    if (data.api_key != null) $("cfg-api-key").value = data.api_key;
+    if (data.base_url != null) $("cfg-base-url").value = data.base_url;
+    if (data.model != null) $("cfg-model").value = data.model;
+    if (data.debate_temperature != null) $("cfg-debate-temp").value = data.debate_temperature;
+    if (data.synthesizer_temperature != null) $("cfg-synth-temp").value = data.synthesizer_temperature;
+    if (data.max_tokens != null) $("cfg-max-tokens").value = data.max_tokens;
+    if (data.timeout != null) $("cfg-timeout").value = data.timeout;
+    if (data.use_evidence_bundle != null) $("cfg-use-evidence").checked = data.use_evidence_bundle;
+  }
+
+  function loadStorage() {
+    try {
+      const raw = localStorage.getItem(STORAGE_KEY);
+      if (!raw) return;
+      const data = JSON.parse(raw);
+      applyForm(data);
+    } catch (_) {
+      /* ignore */
+    }
+  }
+
+  function saveStorage() {
+    const data = readForm();
+    localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
+  }
+
+  function clearStorage() {
+    localStorage.removeItem(STORAGE_KEY);
+  }
+
+  function renderMarkdown(md) {
+    const el = $("output-md");
+    if (!md) {
+      el.innerHTML = "";
+      return;
+    }
+    if (typeof marked === "undefined" || typeof DOMPurify === "undefined") {
+      el.textContent = md;
+      return;
+    }
+    const raw = marked.parse(md, { mangle: false, headerIds: false });
+    el.innerHTML = DOMPurify.sanitize(raw);
+  }
+
+  function setStatus(text) {
+    $("status-text").textContent = text || "";
+  }
+
+  function setError(msg) {
+    const box = $("output-error");
+    if (!msg) {
+      box.classList.add("hidden");
+      box.textContent = "";
+      return;
+    }
+    box.textContent = msg;
+    box.classList.remove("hidden");
+  }
+
+  function padTime(n) {
+    return n < 10 ? "0" + n : String(n);
+  }
+
+  function nowTime() {
+    const d = new Date();
+    return `${padTime(d.getHours())}:${padTime(d.getMinutes())}:${padTime(d.getSeconds())}`;
+  }
+
+  function appendLog(text) {
+    const log = $("progress-log");
+    const line = document.createElement("div");
+    line.className = "log-line";
+    line.innerHTML = `<time>${nowTime()}</time>${escapeHtml(text)}`;
+    log.appendChild(line);
+    log.scrollTop = log.scrollHeight;
+  }
+
+  function escapeHtml(s) {
+    const d = document.createElement("div");
+    d.textContent = s;
+    return d.innerHTML;
+  }
+
+  function setProgressBar(step, total) {
+    const totalN = typeof total === "number" && total > 0 ? total : 15;
+    const stepN = typeof step === "number" ? step : 0;
+    const pct = Math.min(100, Math.round(((stepN + 1) / totalN) * 100));
+    const fill = $("progress-fill");
+    const track = $("progress-track");
+    fill.style.width = pct + "%";
+    track.setAttribute("aria-valuenow", String(pct));
+    $("progress-meta").textContent = `进度约 ${pct}%(阶段 ${Math.min(stepN + 1, totalN)} / ${totalN})`;
+  }
+
+  function resetProgressUi() {
+    $("progress-log").innerHTML = "";
+    $("progress-fill").style.width = "0%";
+    $("progress-track").setAttribute("aria-valuenow", "0");
+    $("progress-meta").textContent = "准备开始…";
+  }
+
+  function handleStreamEvent(ev, state) {
+    const t = ev.total;
+    const s = typeof ev.step === "number" ? ev.step : state.lastStep;
+
+    switch (ev.event) {
+      case "progress":
+        appendLog(ev.message || "");
+        setStatus(ev.message || "");
+        if (typeof ev.step === "number") {
+          state.lastStep = ev.step;
+          setProgressBar(ev.step, ev.total);
+        }
+        break;
+      case "evidence_done":
+        appendLog(`考据附录已就绪(约 ${ev.chars || 0} 字)`);
+        if (ev.preview) appendLog("附录预览:" + ev.preview.slice(0, 200).replace(/\s+/g, " ") + "…");
+        setProgressBar(ev.step, t);
+        break;
+      case "round1_start":
+      case "round2_start":
+      case "digest_start":
+      case "synthesis_start":
+        appendLog(ev.message || `${ev.event} · ${ev.role || ""}`);
+        setStatus(ev.message || "调用模型中…");
+        if (typeof ev.step === "number") {
+          state.lastStep = ev.step;
+          setProgressBar(ev.step, t);
+        }
+        break;
+      case "round1_end":
+      case "round2_end":
+      case "digest_end":
+      case "synthesis_end":
+        if (ev.markdown_section) {
+          state.md += ev.markdown_section;
+          renderMarkdown(state.md);
+        }
+        appendLog(`✓ 已完成:${ev.role ? ev.role : ev.event.replace("_end", "")}`);
+        if (typeof ev.step === "number") {
+          state.lastStep = ev.step;
+          setProgressBar(ev.step, t);
+        }
+        break;
+      case "complete":
+        state.completed = true;
+        if (ev.markdown) {
+          state.md = ev.markdown;
+          renderMarkdown(state.md);
+        }
+        setProgressBar((t || 15) - 1, t || 15);
+        $("progress-meta").textContent = "全部完成";
+        setStatus("完成");
+        appendLog(ev.message || "全部完成");
+        break;
+      case "error":
+        setError(ev.message || "未知错误");
+        setStatus("");
+        appendLog("错误:" + (ev.message || ""));
+        break;
+      default:
+        break;
+    }
+  }
+
+  async function consumeSseStream(response, state) {
+    const reader = response.body.getReader();
+    const decoder = new TextDecoder();
+    let buffer = "";
+
+    while (true) {
+      const { done, value } = await reader.read();
+      if (done) break;
+      buffer += decoder.decode(value, { stream: true });
+      const parts = buffer.split("\n\n");
+      buffer = parts.pop() || "";
+
+      for (const block of parts) {
+        const line = block.trim();
+        if (!line.startsWith("data:")) continue;
+        const jsonStr = line.slice(5).trim();
+        if (!jsonStr) continue;
+        let ev;
+        try {
+          ev = JSON.parse(jsonStr);
+        } catch {
+          continue;
+        }
+        handleStreamEvent(ev, state);
+        if (ev.event === "error") return false;
+      }
+    }
+    return !!state.completed;
+  }
+
+  async function runDebate() {
+    const topic = $("topic-input").value.trim();
+    if (!topic) {
+      setError("请先填写历史议题。");
+      return;
+    }
+
+    const cfg = readForm();
+    $("btn-run").disabled = true;
+    setError("");
+    resetProgressUi();
+    renderMarkdown("");
+    setStatus("连接服务器…");
+    appendLog("已提交议题,等待流式响应…");
+
+    const body = {
+      topic,
+      api_key: cfg.api_key || null,
+      base_url: cfg.base_url || null,
+      model: cfg.model || null,
+      debate_temperature: cfg.debate_temperature,
+      synthesizer_temperature: cfg.synthesizer_temperature,
+      max_tokens: cfg.max_tokens,
+      timeout: cfg.timeout,
+      use_evidence_bundle: cfg.use_evidence_bundle,
+    };
+
+    const state = { md: "", lastStep: 0, completed: false };
+    const ac = new AbortController();
+    const to = setTimeout(() => ac.abort(), 920000);
+
+    try {
+      const res = await fetch("/api/debate/stream", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(body),
+        signal: ac.signal,
+      });
+
+      if (!res.ok) {
+        const text = await res.text();
+        setError("HTTP " + res.status + " " + text.slice(0, 200));
+        setStatus("");
+        return;
+      }
+
+      const ct = res.headers.get("content-type") || "";
+      if (!ct.includes("text/event-stream")) {
+        setError("服务器未返回事件流,请重启 Web 服务后重试。");
+        setStatus("");
+        return;
+      }
+
+      const ok = await consumeSseStream(res, state);
+      state.completed = ok;
+      if (!ok && !$("output-error").textContent) {
+        setError("流式输出意外结束,请查看日志或重试。");
+      }
+    } catch (e) {
+      const msg = e.name === "AbortError" ? "等待超时,请重试或缩小议题。" : String(e);
+      setError(msg);
+      setStatus("");
+      appendLog(msg);
+    } finally {
+      clearTimeout(to);
+      $("btn-run").disabled = false;
+    }
+  }
+
+  $("btn-run").addEventListener("click", runDebate);
+
+  $("btn-save-config").addEventListener("click", () => {
+    if ($("cfg-remember").checked) {
+      saveStorage();
+      setStatus("已保存到本机浏览器");
+    } else {
+      setStatus("请勾选「记住配置」后再保存,或仅使用当前页面临时配置");
+    }
+  });
+
+  $("btn-clear-config").addEventListener("click", () => {
+    clearStorage();
+    $("cfg-api-key").value = "";
+    setStatus("已清除本地保存的配置");
+  });
+
+  $("btn-toggle-config").addEventListener("click", () => {
+    const panel = $("config-panel");
+    const collapsed = panel.classList.toggle("collapsed");
+    $("btn-toggle-config").setAttribute("aria-expanded", String(!collapsed));
+    $("btn-toggle-config").textContent = collapsed ? "展开配置" : "收起配置";
+  });
+
+  loadStorage();
+})();

+ 117 - 0
Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/web/static/index.html

@@ -0,0 +1,117 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>史观交锋 · 多角色历史辩论</title>
+  <link rel="preconnect" href="https://fonts.googleapis.com" />
+  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+  <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,600;0,9..40,700;1,9..40,400&family=Noto+Serif+SC:wght@400;600;700&display=swap" rel="stylesheet" />
+  <link rel="stylesheet" href="/static/style.css" />
+</head>
+<body>
+  <div class="grain" aria-hidden="true"></div>
+  <header class="site-header">
+    <div class="brand">
+      <span class="brand-mark" aria-hidden="true">史</span>
+      <div>
+        <h1>史观交锋</h1>
+        <p class="tagline">官修≠真相 · 野史与边缘叙事 · 政治与权力语境 · 域外之镜 · 蹊跷与阴谋论辨析 → 怀疑中综合</p>
+      </div>
+    </div>
+    <button type="button" class="btn ghost" id="btn-toggle-config" aria-expanded="true">收起配置</button>
+  </header>
+
+  <div class="layout">
+    <aside class="panel config-panel" id="config-panel">
+      <h2 class="panel-title">模型与运行配置</h2>
+      <p class="hint">以下配置可在浏览器内保存(本地 <code>localStorage</code>),不会自动写入服务器 <code>.env</code>。</p>
+
+      <label class="field">
+        <span>OpenRouter API Key</span>
+        <input type="password" id="cfg-api-key" autocomplete="off" placeholder="sk-or-v1-… 或留空用服务端环境变量" />
+      </label>
+
+      <label class="field">
+        <span>Base URL</span>
+        <input type="url" id="cfg-base-url" placeholder="https://openrouter.ai/api/v1" />
+      </label>
+
+      <label class="field">
+        <span>模型 ID</span>
+        <input type="text" id="cfg-model" placeholder="openai/gpt-4o-mini" />
+      </label>
+
+      <div class="field-row">
+        <label class="field compact">
+          <span>辩论温度</span>
+          <input type="number" id="cfg-debate-temp" min="0" max="2" step="0.01" value="0.72" />
+        </label>
+        <label class="field compact">
+          <span>综合温度</span>
+          <input type="number" id="cfg-synth-temp" min="0" max="2" step="0.01" value="0.22" />
+        </label>
+      </div>
+
+      <div class="field-row">
+        <label class="field compact">
+          <span>Max tokens</span>
+          <input type="number" id="cfg-max-tokens" min="256" max="128000" step="256" value="4096" />
+        </label>
+        <label class="field compact">
+          <span>请求超时(s)</span>
+          <input type="number" id="cfg-timeout" min="30" max="600" step="10" value="180" />
+        </label>
+      </div>
+
+      <label class="check">
+        <input type="checkbox" id="cfg-use-evidence" checked />
+        <span>启用维基 + DuckDuckGo 考据附录</span>
+      </label>
+
+      <label class="check">
+        <input type="checkbox" id="cfg-remember" />
+        <span>记住配置到本机浏览器(含 API Key,请勿在公共电脑勾选)</span>
+      </label>
+
+      <div class="btn-row">
+        <button type="button" class="btn secondary" id="btn-save-config">保存配置</button>
+        <button type="button" class="btn ghost" id="btn-clear-config">清除本地配置</button>
+      </div>
+    </aside>
+
+    <main class="panel main-panel">
+      <h2 class="panel-title">议题</h2>
+      <textarea id="topic-input" rows="5" placeholder="例如:靖康之变中主流叙事与常见阴谋论说法,哪些较可信?需要哪些史料才能定论?"></textarea>
+
+      <div class="btn-row main-actions">
+        <button type="button" class="btn primary" id="btn-run">开始辩论</button>
+        <span class="status" id="status-text"></span>
+      </div>
+
+      <section class="progress-section" id="progress-section" aria-live="polite">
+        <h2 class="panel-title">进行状态</h2>
+        <div class="progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" id="progress-track">
+          <div class="progress-fill" id="progress-fill"></div>
+        </div>
+        <p class="progress-meta" id="progress-meta">就绪</p>
+        <div class="progress-log" id="progress-log"></div>
+      </section>
+
+      <section class="output-section" aria-live="polite">
+        <h2 class="panel-title">辩论正文(随步骤追加)</h2>
+        <div id="output-error" class="error-banner hidden" role="alert"></div>
+        <article id="output-md" class="markdown-body"></article>
+      </section>
+    </main>
+  </div>
+
+  <footer class="site-footer">
+    <p>输出仅供思辨与学习;密钥请勿提交到 Git。流式展示进度;完整流程约 12 次模型调用(五角色两轮 + 秘书 + 终局),请勿关闭标签页。</p>
+  </footer>
+
+  <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
+  <script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js"></script>
+  <script src="/static/app.js"></script>
+</body>
+</html>

+ 442 - 0
Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/web/static/style.css

@@ -0,0 +1,442 @@
+:root {
+  --bg-deep: #0c0e12;
+  --bg-panel: #14181f;
+  --bg-elevated: #1c222c;
+  --border: #2a3344;
+  --text: #e6e2db;
+  --text-muted: #8b93a4;
+  --gold: #c9a962;
+  --gold-dim: #8a7344;
+  --accent: #6b9bd1;
+  --danger: #e07070;
+  --radius: 12px;
+  --font-serif: "Noto Serif SC", "Source Han Serif SC", serif;
+  --font-sans: "DM Sans", system-ui, sans-serif;
+}
+
+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+}
+
+html {
+  font-size: 16px;
+  scroll-behavior: smooth;
+}
+
+body {
+  margin: 0;
+  min-height: 100vh;
+  font-family: var(--font-sans);
+  color: var(--text);
+  background: var(--bg-deep);
+  line-height: 1.55;
+}
+
+.grain {
+  pointer-events: none;
+  position: fixed;
+  inset: 0;
+  opacity: 0.04;
+  background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
+  z-index: 0;
+}
+
+.site-header,
+.layout,
+.site-footer {
+  position: relative;
+  z-index: 1;
+}
+
+.site-header {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 1rem;
+  padding: 1.5rem clamp(1rem, 4vw, 2.5rem);
+  border-bottom: 1px solid var(--border);
+  background: linear-gradient(180deg, rgba(201, 169, 98, 0.06), transparent);
+}
+
+.brand {
+  display: flex;
+  gap: 1rem;
+  align-items: center;
+}
+
+.brand-mark {
+  width: 3rem;
+  height: 3rem;
+  display: grid;
+  place-items: center;
+  font-family: var(--font-serif);
+  font-weight: 700;
+  font-size: 1.5rem;
+  color: var(--bg-deep);
+  background: linear-gradient(145deg, var(--gold), var(--gold-dim));
+  border-radius: 10px;
+  box-shadow: 0 4px 24px rgba(201, 169, 98, 0.25);
+}
+
+.site-header h1 {
+  margin: 0;
+  font-family: var(--font-serif);
+  font-size: clamp(1.5rem, 4vw, 2rem);
+  font-weight: 700;
+  letter-spacing: 0.06em;
+  color: var(--gold);
+}
+
+.tagline {
+  margin: 0.25rem 0 0;
+  font-size: 0.85rem;
+  color: var(--text-muted);
+  max-width: 36rem;
+}
+
+.layout {
+  display: grid;
+  grid-template-columns: minmax(280px, 340px) 1fr;
+  gap: 1.25rem;
+  padding: 1.25rem clamp(1rem, 3vw, 2rem) 3rem;
+  align-items: start;
+}
+
+.config-panel.collapsed {
+  display: none;
+}
+
+@media (max-width: 900px) {
+  .layout {
+    grid-template-columns: 1fr;
+  }
+}
+
+.panel {
+  background: var(--bg-panel);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  padding: 1.25rem 1.35rem;
+}
+
+.panel-title {
+  margin: 0 0 1rem;
+  font-family: var(--font-serif);
+  font-size: 1.05rem;
+  font-weight: 600;
+  color: var(--gold);
+  letter-spacing: 0.04em;
+}
+
+.hint {
+  font-size: 0.8rem;
+  color: var(--text-muted);
+  margin: 0 0 1rem;
+  line-height: 1.45;
+}
+
+.hint code {
+  font-size: 0.78rem;
+  background: var(--bg-elevated);
+  padding: 0.1rem 0.35rem;
+  border-radius: 4px;
+}
+
+.field {
+  display: flex;
+  flex-direction: column;
+  gap: 0.35rem;
+  margin-bottom: 0.9rem;
+}
+
+.field > span {
+  font-size: 0.78rem;
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+  color: var(--text-muted);
+}
+
+.field input[type="text"],
+.field input[type="url"],
+.field input[type="password"],
+.field input[type="number"],
+textarea {
+  width: 100%;
+  padding: 0.55rem 0.75rem;
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  background: var(--bg-elevated);
+  color: var(--text);
+  font-family: inherit;
+  font-size: 0.9rem;
+}
+
+.field input:focus,
+textarea:focus {
+  outline: none;
+  border-color: var(--gold-dim);
+  box-shadow: 0 0 0 2px rgba(201, 169, 98, 0.15);
+}
+
+.field-row {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 0.75rem;
+}
+
+.field.compact {
+  margin-bottom: 0.75rem;
+}
+
+.check {
+  display: flex;
+  align-items: flex-start;
+  gap: 0.5rem;
+  font-size: 0.85rem;
+  color: var(--text-muted);
+  margin-bottom: 0.65rem;
+  cursor: pointer;
+}
+
+.check input {
+  margin-top: 0.2rem;
+  accent-color: var(--gold);
+}
+
+.btn-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.5rem;
+  margin-top: 1rem;
+}
+
+.btn {
+  font-family: inherit;
+  font-size: 0.88rem;
+  font-weight: 600;
+  padding: 0.5rem 1rem;
+  border-radius: 8px;
+  border: 1px solid transparent;
+  cursor: pointer;
+  transition: background 0.15s, border-color 0.15s, transform 0.1s;
+}
+
+.btn:active {
+  transform: scale(0.98);
+}
+
+.btn.primary {
+  background: linear-gradient(145deg, var(--gold), #a88b42);
+  color: var(--bg-deep);
+  border-color: rgba(255, 255, 255, 0.12);
+}
+
+.btn.primary:hover {
+  filter: brightness(1.06);
+}
+
+.btn.secondary {
+  background: var(--bg-elevated);
+  color: var(--text);
+  border-color: var(--border);
+}
+
+.btn.secondary:hover {
+  border-color: var(--gold-dim);
+}
+
+.btn.ghost {
+  background: transparent;
+  color: var(--text-muted);
+  border-color: var(--border);
+}
+
+.btn.ghost:hover {
+  color: var(--text);
+  border-color: var(--text-muted);
+}
+
+.btn:disabled {
+  opacity: 0.45;
+  cursor: not-allowed;
+  transform: none;
+}
+
+.main-panel textarea {
+  resize: vertical;
+  min-height: 120px;
+}
+
+.main-actions {
+  align-items: center;
+}
+
+.status {
+  font-size: 0.85rem;
+  color: var(--accent);
+}
+
+.progress-section {
+  margin-top: 1.25rem;
+  padding: 1rem 0 1.25rem;
+  border-top: 1px solid var(--border);
+  border-bottom: 1px solid var(--border);
+}
+
+.progress-track {
+  height: 8px;
+  border-radius: 999px;
+  background: var(--bg-elevated);
+  border: 1px solid var(--border);
+  overflow: hidden;
+}
+
+.progress-fill {
+  height: 100%;
+  width: 0%;
+  border-radius: 999px;
+  background: linear-gradient(90deg, var(--gold-dim), var(--gold));
+  transition: width 0.35s ease;
+}
+
+.progress-meta {
+  margin: 0.5rem 0 0.75rem;
+  font-size: 0.88rem;
+  color: var(--accent);
+}
+
+.progress-log {
+  max-height: 200px;
+  overflow-y: auto;
+  font-family: ui-monospace, "Cascadia Code", monospace;
+  font-size: 0.78rem;
+  line-height: 1.5;
+  color: var(--text-muted);
+  background: var(--bg-elevated);
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  padding: 0.65rem 0.75rem;
+}
+
+.progress-log .log-line {
+  margin: 0.2rem 0;
+  border-left: 2px solid var(--gold-dim);
+  padding-left: 0.5rem;
+}
+
+.progress-log .log-line time {
+  color: var(--gold-dim);
+  margin-right: 0.35rem;
+}
+
+.output-section {
+  margin-top: 1.5rem;
+  padding-top: 1.25rem;
+  border-top: 1px solid var(--border);
+}
+
+.error-banner {
+  padding: 0.75rem 1rem;
+  border-radius: 8px;
+  background: rgba(224, 112, 112, 0.12);
+  border: 1px solid rgba(224, 112, 112, 0.35);
+  color: var(--danger);
+  font-size: 0.9rem;
+  margin-bottom: 1rem;
+}
+
+.error-banner.hidden {
+  display: none;
+}
+
+.markdown-body {
+  font-family: var(--font-serif);
+  font-size: 0.95rem;
+  line-height: 1.65;
+  color: var(--text);
+}
+
+.markdown-body h1,
+.markdown-body h2,
+.markdown-body h3 {
+  font-family: var(--font-serif);
+  color: var(--gold);
+  margin-top: 1.4em;
+  margin-bottom: 0.5em;
+}
+
+.markdown-body h1 {
+  font-size: 1.35rem;
+}
+
+.markdown-body h2 {
+  font-size: 1.15rem;
+}
+
+.markdown-body h3 {
+  font-size: 1.05rem;
+}
+
+.markdown-body p {
+  margin: 0.65em 0;
+}
+
+.markdown-body ul,
+.markdown-body ol {
+  padding-left: 1.35rem;
+}
+
+.markdown-body code {
+  font-family: ui-monospace, monospace;
+  font-size: 0.86em;
+  background: var(--bg-elevated);
+  padding: 0.12em 0.35em;
+  border-radius: 4px;
+}
+
+.markdown-body pre {
+  background: var(--bg-elevated);
+  border: 1px solid var(--border);
+  padding: 1rem;
+  border-radius: 8px;
+  overflow-x: auto;
+}
+
+.markdown-body pre code {
+  background: none;
+  padding: 0;
+}
+
+.markdown-body blockquote {
+  margin: 1em 0;
+  padding: 0.4em 0 0.4em 1em;
+  border-left: 3px solid var(--gold-dim);
+  color: var(--text-muted);
+}
+
+.markdown-body hr {
+  border: none;
+  border-top: 1px solid var(--border);
+  margin: 2rem 0;
+}
+
+.markdown-body a {
+  color: var(--accent);
+}
+
+.site-footer {
+  padding: 1rem clamp(1rem, 3vw, 2rem) 2rem;
+  font-size: 0.8rem;
+  color: var(--text-muted);
+  text-align: center;
+  border-top: 1px solid var(--border);
+}
+
+.site-footer p {
+  margin: 0;
+  max-width: 42rem;
+  margin-inline: auto;
+}

+ 280 - 0
Co-creation-projects/meiguanxiHXX-historyReviewAgent/historical_review/wiki_tools.py

@@ -0,0 +1,280 @@
+"""维基百科开放 API:多语种条目检索与对照(无需密钥,需遵守使用规范)。"""
+
+from __future__ import annotations
+
+from typing import Any
+from urllib.parse import urlencode
+
+import requests
+
+_WIKI_UA = (
+    "HelloAgentsHistoricalReview/1.0 (educational; https://github.com/datawhalechina/hello-agents)"
+)
+_SESSION = requests.Session()
+_SESSION.headers.update({"User-Agent": _WIKI_UA})
+
+
+def _get(lang: str, params: dict[str, Any]) -> dict[str, Any]:
+    host = f"https://{lang}.wikipedia.org/w/api.php"
+    r = _SESSION.get(host, params=params, timeout=25)
+    r.raise_for_status()
+    return r.json()
+
+
+def wiki_search(params: str) -> str:
+    """
+    在指定语言维基中按关键词搜索条目标题。
+
+    参数格式:`语言代码###关键词`
+    示例:`zh###安史之乱`、`en###Fall of Constantinople`
+    """
+    raw = (params or "").strip()
+    if "###" not in raw:
+        return "错误:格式应为 语言代码###关键词,例如 zh###靖康之变"
+    lang, _, q = raw.partition("###")
+    lang, q = lang.strip().lower(), q.strip()
+    if not lang or not q:
+        return "错误:语言和关键词均不能为空。"
+
+    data = _get(
+        lang,
+        {
+            "action": "opensearch",
+            "search": q,
+            "limit": 8,
+            "namespace": 0,
+            "format": "json",
+        },
+    )
+    # opensearch: [term, [titles], [desc], [urls]]
+    if not isinstance(data, list) or len(data) < 2:
+        return f"[{lang}] 搜索无结果或接口异常。"
+    titles = data[1] if len(data) > 1 else []
+    descs = data[2] if len(data) > 2 else []
+    if not titles:
+        return f"[{lang}] 未找到与「{q}」匹配的条目,可换 en###同一主题的英文检索词再试。"
+
+    lines = [f"[{lang}.wikipedia] 关键词「{q}」候选条目:"]
+    for i, t in enumerate(titles):
+        d = descs[i] if i < len(descs) else ""
+        lines.append(f"  {i+1}. {t} — {d[:200]}")
+    lines.append("\n建议:用 wiki_article 拉取全文摘录,或 wiki_multiview 做中英日等多语种对照。")
+    return "\n".join(lines)
+
+
+def wiki_article(params: str) -> str:
+    """
+    获取维基条目纯文本摘录(非导语部分也会尽量多取字符)。
+
+    参数格式:`语言代码###条目名`(条目名需与站内标题一致或接近)
+    示例:`zh###岳飞`、`en###Qin Shi Huang`
+    """
+    raw = (params or "").strip()
+    if "###" not in raw:
+        return "错误:格式应为 语言代码###条目名,例如 zh###王安石"
+    lang, _, title = raw.partition("###")
+    lang, title = lang.strip().lower(), title.strip()
+    if not lang or not title:
+        return "错误:语言或条目名为空。"
+
+    data = _get(
+        lang,
+        {
+            "action": "query",
+            "titles": title,
+            "prop": "extracts",
+            "explaintext": 1,
+            "exchars": 10000,
+            "format": "json",
+        },
+    )
+    pages = data.get("query", {}).get("pages", {})
+    out: list[str] = []
+    for _pid, page in pages.items():
+        if int(_pid) < 0 or page.get("missing"):
+            out.append(f"[{lang}] 未找到条目「{title}」。请先用 wiki_search 查准确标题。")
+            continue
+        t = page.get("title", title)
+        ex = (page.get("extract") or "").strip()
+        if not ex:
+            out.append(f"[{lang}]「{t}」无正文摘录(可能是消歧义页)。")
+            continue
+        if len(ex) > 11000:
+            ex = ex[:11000] + "\n... [截断]"
+        url = f"https://{lang}.wikipedia.org/wiki/{title.replace(' ', '_')}"
+        out.append(f"=== {lang}.wikipedia / {t} ===\n{url}\n\n{ex}")
+    return "\n\n".join(out) if out else "未获取到内容。"
+
+
+def wiki_langlinks(params: str) -> str:
+    """
+    列出某条目在其他语言维基中的对应标题(便于横向对比域外叙述)。
+
+    参数格式:`语言代码###条目名`
+    """
+    raw = (params or "").strip()
+    if "###" not in raw:
+        return "错误:格式应为 语言代码###条目名"
+    lang, _, title = raw.partition("###")
+    lang, title = lang.strip().lower(), title.strip()
+
+    data = _get(
+        lang,
+        {
+            "action": "query",
+            "titles": title,
+            "prop": "langlinks",
+            "lllimit": 50,
+            "format": "json",
+        },
+    )
+    pages = data.get("query", {}).get("pages", {})
+    if not pages:
+        return "未查询到页面。"
+    lines: list[str] = []
+    for _pid, page in pages.items():
+        if page.get("missing"):
+            return f"未找到「{title}」。"
+        resolved = page.get("title", title)
+        links = page.get("langlinks") or []
+        if not links:
+            return f"「{resolved}」暂无其他语言链接,可换 en/zh 起搜或直接用 search 找外国史籍研究。"
+        lines.append(f"条目「{resolved}」({lang}.wiki) 的部分语种对应:")
+        for ll in links[:40]:
+            lines.append(f"  - {ll.get('lang')}: {ll.get('*')}")
+    return "\n".join(lines)
+
+
+def _query_page_extract_and_links(
+    lang: str, title: str
+) -> tuple[str | None, str | None, dict[str, str]]:
+    """返回 (resolved_title, extract_plain, langlinks map lang_code->foreign_title)。"""
+    data = _get(
+        lang,
+        {
+            "action": "query",
+            "titles": title,
+            "prop": "langlinks|extracts",
+            "lllang": "en|ja|ko|zh|fr|de",
+            "lllimit": 30,
+            "explaintext": 1,
+            "exchars": 5000,
+            "format": "json",
+        },
+    )
+    pages = data.get("query", {}).get("pages", {})
+    for _pid, page in pages.items():
+        if page.get("missing"):
+            return None, None, {}
+        resolved = page.get("title", title)
+        ex = (page.get("extract") or "").strip()
+        links = {ll["lang"]: ll["*"] for ll in (page.get("langlinks") or [])}
+        return resolved, ex or None, links
+    return None, None, {}
+
+
+def _looks_cjk(text: str) -> bool:
+    return any("\u4e00" <= c <= "\u9fff" for c in text)
+
+
+def wiki_multiview(params: str) -> str:
+    """
+    以关键词起搜:含汉字时优先中文维基;纯拉丁字母等则优先英文维基(避免误匹配)。
+    再拉取关联语种(如英/日/中与主站交叉)条目摘录并列。
+    """
+    q = (params or "").strip()
+    if not q:
+        return "错误:请提供历史事件或人物关键词。"
+
+    blocks: list[str] = []
+    targets: list[tuple[str, str]] = []
+    seen_titles: set[tuple[str, str]] = set()
+
+    def add_block(lang: str, title: str, label: str, excerpt: str) -> None:
+        key = (lang, title)
+        if key in seen_titles:
+            return
+        seen_titles.add(key)
+        if len(excerpt) > 5500:
+            excerpt = excerpt[:5500] + "..."
+        blocks.append(
+            f"{label}{title}\n"
+            f"https://{lang}.wikipedia.org/wiki/{title.replace(' ', '_')}\n\n{excerpt}"
+        )
+
+    primary = "zh" if _looks_cjk(q) else "en"
+    secondary = "en" if primary == "zh" else "zh"
+
+    os_primary = _get(
+        primary,
+        {
+            "action": "opensearch",
+            "search": q,
+            "limit": 5,
+            "namespace": 0,
+            "format": "json",
+        },
+    )
+    p_title = None
+    if isinstance(os_primary, list) and len(os_primary) > 1 and os_primary[1]:
+        p_title = os_primary[1][0]
+
+    if p_title:
+        resolved, ex, links = _query_page_extract_and_links(primary, p_title)
+        if resolved and ex and "may refer to" not in ex.lower() and "消歧义" not in ex[:80]:
+            add_block(primary, resolved, "【主站维基】", ex)
+            order = ["en", "ja", "ko", "zh", "fr", "de"] if primary == "zh" else ["zh", "ja", "ko", "en"]
+            for code in order:
+                if code == primary:
+                    continue
+                if code in links:
+                    targets.append((code, links[code]))
+
+    if not blocks:
+        os_sec = _get(
+            secondary,
+            {
+                "action": "opensearch",
+                "search": q,
+                "limit": 5,
+                "namespace": 0,
+                "format": "json",
+            },
+        )
+        s_title = None
+        if isinstance(os_sec, list) and len(os_sec) > 1 and os_sec[1]:
+            s_title = os_sec[1][0]
+        if s_title:
+            resolved, ex, links = _query_page_extract_and_links(secondary, s_title)
+            if resolved and ex:
+                add_block(secondary, resolved, "【备用语种维基】", ex)
+                order = ["zh", "en", "ja", "ko"] if secondary == "en" else ["en", "ja", "ko"]
+                for code in order:
+                    if code == secondary:
+                        continue
+                    if code in links:
+                        targets.append((code, links[code]))
+
+    for lang, tit in targets:
+        key = (lang, tit)
+        if key in seen_titles:
+            continue
+        snippet = wiki_article(f"{lang}###{tit}")
+        if snippet.startswith("错误") or "未找到条目" in snippet:
+            continue
+        blocks.append(f"\n--- 对照语种 {lang} ---\n{snippet}")
+
+    if not blocks:
+        return (
+            f"未能为「{q}」自动匹配到维基正文。请用 wiki_search 分别试 zh### 与 en###,"
+            "或使用 search 检索学术/史料网页后再 fetch_url_text。"
+        )
+
+    header = (
+        f"多语种维基摘录对照(关键词:{q})。注意:维基为二手综述,非原始档案;"
+        "不同语种条目由不同社群编写,立场与侧重可能不同。\n"
+    )
+    body = "\n\n".join(blocks)
+    if len(header) + len(body) > 28000:
+        body = body[: 28000 - len(header)] + "\n... [总长度已截断]"
+    return header + body

+ 30 - 0
Co-creation-projects/meiguanxiHXX-historyReviewAgent/pyproject.toml

@@ -0,0 +1,30 @@
+[build-system]
+requires = ["setuptools>=61"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "chapter16-historical-review"
+version = "0.1.0"
+description = "第十六章:多角色历史辩论示例(historical_review)"
+readme = "README.md"
+requires-python = ">=3.10"
+dependencies = [
+    "hello-agents>=0.1.0",
+    "python-dotenv>=1.0.0",
+    "requests>=2.31.0",
+    "duckduckgo-search>=7.0.0",
+    "huggingface_hub>=0.25.0",
+    "fastapi>=0.115.0",
+    "uvicorn[standard]>=0.32.0",
+]
+
+[project.scripts]
+history-review = "historical_review.run_agent:main"
+history-review-web = "historical_review.web.cli:main"
+
+[tool.setuptools.packages.find]
+where = ["."]
+include = ["historical_review*"]
+
+[tool.setuptools.package-data]
+"historical_review.web" = ["static/*.html", "static/*.css", "static/*.js"]

+ 7 - 0
Co-creation-projects/meiguanxiHXX-historyReviewAgent/requirements.txt

@@ -0,0 +1,7 @@
+hello-agents>=0.1.0
+python-dotenv>=1.0.0
+requests>=2.31.0
+duckduckgo-search>=7.0.0
+huggingface_hub>=0.25.0
+fastapi>=0.115.0
+uvicorn[standard]>=0.32.0

+ 8 - 0
Co-creation-projects/meiguanxiHXX-historyReviewAgent/run_web.py

@@ -0,0 +1,8 @@
+"""启动史观交锋 Web。"""
+
+from __future__ import annotations
+
+from historical_review.web.cli import main
+
+if __name__ == "__main__":
+    main()