From 1e4889f58d2f5c21d6dedb6fcde2bbd18c9f07ed Mon Sep 17 00:00:00 2001 From: izukuuuu Date: Fri, 15 May 2026 15:23:56 +0800 Subject: [PATCH 01/12] =?UTF-8?q?=E5=90=88=E5=B9=B6=E5=8E=9F=E5=A7=8B?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 32 +- cli/display.py | 6 + cli/main.py | 3 +- cli/monitor_ui.py | 278 ++++++++++++- docs/gui_decision.md | 2 +- ...73\345\212\241\347\212\266\346\200\201.py" | 23 +- ...60\345\273\272\344\273\273\345\212\241.py" | 19 +- ...45\345\221\212\346\237\245\347\234\213.py" | 23 +- ...35\344\270\216\350\267\257\347\224\261.py" | 12 +- scripts/fetch_student_prs.sh | 0 scripts/map_pr_golden_to_eval_case.py | 369 ++++++++++++++++++ scripts/run_topic_monitor_tick.py | 76 ++++ streamlit_app.py | 128 +++++- streamlit_ui_theme.py | 56 ++- tests/conftest.py | 2 + .../test_topic_monitoring_pipeline.py | 30 ++ .../test_topic_netinsight_adapter.py | 28 ++ .../cases/golden_pr_case_01_consumption.json | 73 ++++ .../golden_pr_case_01_consumption/tools.json | 17 + .../golden_pr_source/case_01_consumption.json | 36 ++ tools/oprag.py | 136 ++++++- utils/hot_time_parser.py | 84 ++++ workflow/topic_monitoring_pipeline.py | 165 +++++++- workflow/topic_monitoring_workflow.py | 214 ++++++++++ workflow/topic_netinsight_adapter.py | 336 ++++++++++++++++ workflow/wiki_cli.py | 7 + 26 files changed, 2094 insertions(+), 61 deletions(-) mode change 100755 => 100644 scripts/fetch_student_prs.sh create mode 100644 scripts/map_pr_golden_to_eval_case.py create mode 100644 scripts/run_topic_monitor_tick.py create mode 100644 tests/contracts/test_topic_netinsight_adapter.py create mode 100644 tests/evals/cases/golden_pr_case_01_consumption.json create mode 100644 tests/fixtures/golden_pr_case_01_consumption/tools.json create mode 100644 tests/fixtures/golden_pr_source/case_01_consumption.json create mode 100644 utils/hot_time_parser.py create mode 100644 workflow/topic_monitoring_workflow.py create mode 100644 workflow/topic_netinsight_adapter.py diff --git a/README.md b/README.md index c7db4fd6..aa6a51c6 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ - **🧠 垂类知识库增强**:支持控烟、健康、交通、大熊猫等领域包,并通过 `workflow/domain_routing.json` 自动注入优先证据 - **🕸️ Graph RAG 可选增强**:通过 Neo4j Aura/本地 Neo4j 召回相似案例、理论框架和处置经验;连接失败时自动降级 - **📚 案例库与专题监测**:完整报告可自动沉淀到 `opinion_analysis_kb/references/wiki/cases/`,并提供 `/case` 相似案例检索与 `/monitor` 专题快照/日报周报演示 -- **🧩 HTTP API 与轻量 GUI**:`sona serve` 提供 FastAPI 接口,`streamlit_app.py` 提供多页 Streamlit 查看器 +- **🧩 HTTP API 与轻量 GUI**:`sona serve` 提供 FastAPI;`streamlit_app.py` 提供 **分析员控制台**(仪表盘 + 多页任务/报告/经典会话) - **📊 报告质量增强**:时间线证据/影响标签、情绪结构、四阶段行动清单、热点风险分级与案例候选输出 ### 支持的模型提供商 @@ -366,7 +366,7 @@ sona serve --host 127.0.0.1 --port 8765 - `/hot` - 运行独立的热点抓取与态势感知流程(可选参数:配置路径) - `/case` - 检索本地案例库,输出相似案例列表与横向对照 - `/monitor` - 运行专题监测命令,支持创建专题、查看状态、生成日报/周报;未配置外部库时可运行内存演示 -- `/wiki` - 基于本地知识库(`opinion_analysis_kb/references/wiki/`)的问答,输出摘要与引用来源 +- `/wiki` - 基于本地知识库(`opinion_analysis_kb/references/wiki/`)的问答,输出摘要与引用来源;可通过环境变量 `SONA_WIKI_AUTO_COMPILE`、`SONA_WIKI_AUTO_COMPILE_ON_QUERY` 控制是否在写入专家研判或每次检索前自动把 `expert_notes/**/*.md` 增量编译进 `wiki/sources`(默认开启;`tests/conftest.py` 对 CI 关闭「检索前编译」) - `/wiki-approve` - 审核并回流高价值候选 - `/clear` - 清除 memory 和 sandbox - `/exit` - 退出程序 @@ -386,6 +386,29 @@ curl http://127.0.0.1:8765/health streamlit run streamlit_app.py ``` +#### 分析员控制台(Streamlit)是什么 + +多页 Streamlit **不是**「第二个舆情监测大屏」,而是给分析员用的 **控制台**: + +| 区域 | 作用 | +|------|------| +| **仪表盘**(`streamlit_app.py`) | 探活 `sona serve`、展示本 API 进程内最近任务、工作流速查与环境变量说明 | +| **新建 / 报告 / 任务** | 走 `POST /v1/analyze-event` 与任务轮询(重操作,可能长时间同步) | +| **案例 / 专题** | 演示案例检索、编辑 `config/topics.yaml` | +| **经典会话** | 与 CLI 一致的对话界面,支持 `/hot`、`/wiki`、事件路由等 | + +UI 采用与 BettaFish「微舆」类似的 **高对比、硬边框** 风格;空状态与 API 离线有统一提示块。 + +**仅跑控制台时的环境变量(常用)** + +| 变量 | 说明 | +|------|------| +| `API_BASE` | Streamlit 访问的 API 根地址,默认 `http://127.0.0.1:8765` | +| `SONA_API_CORS_ORIGINS` | API 的 CORS 白名单(见 `docs/api_design.md`) | +| `.env` 内模型与采集 Key | 与 CLI 相同;缺省则事件分析或采集会失败 | + +知识库目录 `opinion_analysis_kb/` 若被 `.gitignore` 排除,克隆仓库后需按团队约定自行放入或从网盘恢复。 + 主要 API: - `GET /health`:服务探活 @@ -395,6 +418,11 @@ streamlit run streamlit_app.py 更多约定见 `docs/api_design.md` 和 `docs/gui_decision.md`。 +**控制台 / 新建任务常见问题** + +- **`WORKFLOW_ERROR` / `data_collect` / 微博 / `BrowserType.launch: Executable doesn't exist`**:未安装 Playwright 浏览器。在项目根执行 **`playwright install chromium`**(或 `playwright install`),见上文「数据采集」一节。另需配置 **NetInsight** 账号(`NETINSIGHT_USER` / `NETINSIGHT_PASS`),否则会出现「登录失败」。 +- **`ModuleNotFoundError: utils.hot_time_parser`**:请拉取包含 `utils/hot_time_parser.py` 的版本;经典会话页已增加项目根 `sys.path` 兜底。 + **`/hot` 热点流程说明**: - 从公网聚合接口拉取各平台热搜(需本机可访问外网),再在本地用 **OpenAI 兼容 API** 做归纳与报告。 - `.env` 中**至少配置一个**即可自动映射:`QWEN_APIKEY`(Qwen coding plan/OpenAI 兼容,默认)、`OPENAI_APIKEY`、`DEEPSEEK_APIKEY`、`KIMI_APIKEY`(Moonshot);也可显式设置 `INSIGHT_ENGINE_API_KEY`、`QUERY_ENGINE_API_KEY`(及对应的 `*_BASE_URL`、`*_MODEL_NAME`)。 diff --git a/cli/display.py b/cli/display.py index e8c4ef6d..44e1af62 100644 --- a/cli/display.py +++ b/cli/display.py @@ -81,8 +81,14 @@ def print_welcome() -> None: console.print(" [dim] 示例: /event 315晚会舆情分析[/dim]") console.print(" [cyan]/memory[/cyan] - 查看并恢复之前的会话") console.print(" [cyan]/hot[/cyan] - 热点抓取与态势感知(独立流程,生成 HTML)") + console.print(" [cyan]/case[/cyan] - 检索案例库(相似案例对照)") + console.print(" [dim] 示例: /case 高铁服务争议案例[/dim]") + console.print(" [cyan]/monitor[/cyan] - 专题监测(创建专题、日报/周报、内存演示)") + console.print(" [dim] 示例: /monitor demo 或 /monitor help[/dim]") console.print(" [cyan]/wiki[/cyan] - 知识库问答(answer + sources)") console.print(" [dim] 示例: /wiki 什么是舆情反转?[/dim]") + console.print(" [cyan]/wiki-approve[/cyan] - 审核并回流高价值 wiki 候选到 output") + console.print(" [dim] 示例: /wiki-approve 或 /wiki-approve 关键词[/dim]") console.print(" [cyan]/models[/cyan] - 查看所有模型配置") console.print(" [cyan]/tools[/cyan] - 查看所有可用工具") console.print(" [cyan]/clear[/cyan] - 清除 memory 和 sandbox") diff --git a/cli/main.py b/cli/main.py index 9f08cea2..16795615 100644 --- a/cli/main.py +++ b/cli/main.py @@ -150,7 +150,8 @@ def interactive() -> None: # 默认行为:只提示,不创建会话 console.print( "[yellow]提示: 使用 '/new' 开启新会话,'/memory' 恢复会话," - "'/event' 事件分析,'/wiki' 知识问答,'/hot' 热点态势。[/yellow]" + "'/event' 事件分析,'/hot' 热点态势,'/case' 案例库," + "'/monitor' 专题监测,'/wiki' 知识问答。[/yellow]" ) except KeyboardInterrupt: diff --git a/cli/monitor_ui.py b/cli/monitor_ui.py index d43730cf..be3205fe 100644 --- a/cli/monitor_ui.py +++ b/cli/monitor_ui.py @@ -2,22 +2,34 @@ from __future__ import annotations +import os +import re +from datetime import datetime, timezone from pathlib import Path +from typing import List, Optional, Tuple +import jieba from rich.prompt import Prompt from cli.display import console from workflow.topic_monitoring_pipeline import TopicMonitoringPipeline, run_high_speed_rail_demo +_MONITOR_SUBCOMMANDS = frozenset( + {"help", "h", "?", "demo", "list", "create", "status", "report"} +) + def _print_help() -> None: console.print("[cyan]/monitor[/cyan] - 专题监测命令") console.print(" [cyan]/monitor help[/cyan] - 显示命令帮助") console.print(" [cyan]/monitor demo[/cyan] - 运行高铁舆情内存演示") console.print(" [cyan]/monitor list[/cyan] - 列出当前进程内/外部库专题") - console.print(" [cyan]/monitor create 名称|领域|关键词1,关键词2[/cyan]") + console.print(" [cyan]/monitor create 名称|领域|关键词1,关键词2[/cyan] (结构化)") + console.print(" [dim] 自然语言新建(整句即可):[/dim][cyan]/monitor 帮我建立重大交通事故的舆情监测专题[/cyan]") console.print(" [cyan]/monitor status [/cyan] - 查询专题状态") console.print(" [cyan]/monitor report [daily|weekly][/cyan] - 生成日报/周报") + console.print(" [dim] 环境变量:SONA_MONITOR_SKIP_EXTRACT=1 跳过 extract 精炼;SONA_TOPIC_MONITOR_INTERVAL_HOURS 默认采集间隔。[/dim]") + console.print(" [dim] 网察:SONA_TOPIC_MONITOR_USE_OPINION_NETINSIGHT=1 + SONA_OPINION_SYSTEM_ROOT + NETINSIGHT_USER/PASS 时,定时脚本可走 opinion-system 采集链。[/dim]") def _parse_create_args(args: str) -> tuple[str, str, list[str]]: @@ -28,6 +40,227 @@ def _parse_create_args(args: str) -> tuple[str, str, list[str]]: return name, domain, [k for k in keywords if k] +def _looks_like_create_intent(text: str) -> bool: + """判断整句是否像「新建监测专题」的自然语言,而非误拼的子命令。""" + s = text.strip() + if not s: + return False + if any(v in s for v in ("建立", "创建", "新建", "开通")) and any( + x in s for x in ("专题", "监测", "舆情") + ): + return True + if any(x in s for x in ("监测专题", "舆情监测", "舆情专题")): + return True + if "专题" in s and "舆情" in s: + return True + return False + + +def _infer_domain(core: str) -> str: + if any(x in core for x in ("交通", "事故", "高速", "道路", "地铁", "铁路", "物流", "航运", "民航")): + return "交通" + if any(x in core for x in ("医", "药", "院", "疾控", "健康", "疫情", "控烟")): + return "公共卫生" + if any(x in core for x in ("教育", "学校", "大学", "招生")): + return "教育" + if any(x in core for x in ("环保", "污染", "碳", "气候")): + return "生态环境" + return "综合舆情" + + +def _keywords_from_core(core: str, domain: str) -> List[str]: + """从主题核心短语拆出监测关键词(去重、保留顺序)。""" + kws: List[str] = [] + seen: set[str] = set() + noise = frozenset( + { + "建立", + "创建", + "新建", + "开通", + "专题", + "监测", + "舆情", + "的", + "和", + "与", + "一个", + "帮我", + "我要", + "我想", + } + ) + + def push(w: str) -> None: + w = w.strip() + if len(w) < 2 or w in seen or w in noise: + return + seen.add(w) + kws.append(w) + + push(core) + try: + for w in jieba.lcut(core): + push(w) + except Exception: + pass + if domain == "交通": + for extra in ("交通事故", "交通安全", "追尾"): + if extra in core: + push(extra) + return kws[:16] + + +def _try_parse_natural_create(text: str) -> Optional[Tuple[str, str, List[str]]]: + """ + 从自然语言中解析专题名称、领域、关键词。 + + 例如:「帮我建立 重大交通事故的舆情监测专题」「帮重大交通事故的舆情监测专题」 + """ + s = text.strip() + if not s or not _looks_like_create_intent(s): + return None + + core = s + suffixes = ( + "的舆情监测专题", + "舆情监测专题", + "的舆情专题", + "舆情专题", + "专题监测", + "监测专题", + "的专题", + "的监测", + "舆情监测", + ) + for suf in suffixes: + if core.endswith(suf): + core = core[: -len(suf)].strip() + break + + prefixes = ( + "请帮我", + "能不能帮我", + "麻烦帮我", + "帮我", + "请", + "我想", + "我要", + "给建立一个", + "给我建立", + "给建立", + "建立一个", + "建立", + "创建一个", + "创建", + "新建一个", + "新建", + "开通", + "搞一个", + "做一个", + ) + changed = True + while changed: + changed = False + for pre in sorted(prefixes, key=len, reverse=True): + if core.startswith(pre): + core = core[len(pre) :].strip() + changed = True + break + + # 口语里单独的「帮」+ 主题 + if core.startswith("帮") and len(core) > 1 and core[1] not in "忙": + core = core[1:].strip() + + core = re.sub(r"[\s ,,。]+", "", core) + core = core.strip("的") + if len(core) < 2: + return None + + domain = _infer_domain(core) + if "舆情" in core or "监测" in core: + name = f"{core}专题" if len(core) <= 28 else f"{core[:26]}…专题" + else: + name = f"{core}舆情监测" + if len(name) > 40: + name = f"{core[:32]}…监测" + + keywords = _keywords_from_core(core, domain) + if not keywords: + return None + return name, domain, keywords + + +def _finalize_create_topic( + pipeline: TopicMonitoringPipeline, + *, + name: str, + domain: str, + keywords: List[str], + description: str, + source_label: str, + narrative_query: str | None = None, +) -> None: + if not name or not domain or not keywords: + console.print("[red]专题名称、领域和关键词都不能为空。[/red]") + return + + from workflow.topic_monitoring_workflow import ( + build_default_topic_config, + format_monitor_workflow_hints, + refine_monitor_keywords, + ) + + final_keywords = list(keywords) + merged_plan: dict | None = None + nq = (narrative_query or "").strip() + if nq and os.environ.get("SONA_MONITOR_SKIP_EXTRACT", "").strip().lower() not in ("1", "true", "yes"): + console.print("[dim]调用 extract_search_terms(与事件分析 Step1 同源)精炼监测关键词…[/dim]") + pack = refine_monitor_keywords(user_text=nq, seed_keywords=final_keywords) + merged_plan = pack.get("search_plan") if isinstance(pack.get("search_plan"), dict) else {} + if pack.get("merged_keywords"): + final_keywords = list(pack["merged_keywords"]) + if not pack.get("used_extract"): + err = str(pack.get("extract_error") or "").strip() + if err: + console.print(f"[yellow]关键词精炼未使用模型输出({err}),已保留种子词。[/yellow]") + + topic = pipeline.create_topic( + name=name, + domain=domain, + keywords=final_keywords, + description=description, + ) + tid = str(topic.get("id") or "") + console.print("[green]已创建专题[/green]") + console.print(f"名称: [bold]{name}[/bold]") + console.print(f"领域: {domain}") + console.print(f"关键词: {', '.join(final_keywords)}") + console.print(f"ID: [cyan]{tid}[/cyan]") + console.print(f"[dim]来源: {source_label}[/dim]") + + cfg = build_default_topic_config(merged_search_plan=merged_plan or {}) + cfg["monitoring_started_at"] = datetime.now(timezone.utc).replace(tzinfo=None).isoformat(timespec="seconds") + pipeline.patch_topic_config(tid, cfg) + console.print("[dim]── 监测编排(默认全平台 + 建议间隔)──[/dim]") + for ln in format_monitor_workflow_hints(cfg).split("\n"): + console.print(f"[dim]{ln}[/dim]") + + pipeline.run_monitoring_cycle([tid]) + console.print( + "[dim]已执行首轮监测周期(未注入 search_func / NetInsight 时无外链帖子,快照可能为「无新数据」占位)。[/dim]" + ) + console.print( + "[yellow]定时抓取:[/yellow]请用系统调度每 N 小时执行 " + "``python3 scripts/run_topic_monitor_tick.py``(默认处理全部活跃专题)," + "或在编排层注入 ``run_monitoring_cycle(..., search_func=...)`` 对接网察拉数。" + ) + console.print( + f"[cyan]下一步:[/cyan] `/monitor status {tid}` 查看状态;" + f"`/monitor report {tid} daily` 生成舆情专报(Markdown)。" + ) + + def run_monitor_command(raw_query: str | None = None) -> None: query = str(raw_query or "").strip() if not query: @@ -66,7 +299,15 @@ def run_monitor_command(raw_query: str | None = None) -> None: return if command == "create": - name, domain, keywords = _parse_create_args(rest) + name, domain, keywords = "", "", [] + if rest and "|" in rest: + name, domain, keywords = _parse_create_args(rest) + elif rest: + parsed = _try_parse_natural_create(rest) + if parsed: + name, domain, keywords = parsed + else: + name, domain, keywords = _parse_create_args(rest) if not name: name = Prompt.ask("请输入专题名称").strip() if not domain: @@ -74,12 +315,17 @@ def run_monitor_command(raw_query: str | None = None) -> None: if not keywords: raw_keywords = Prompt.ask("请输入关键词,逗号分隔").strip() keywords = [k.strip() for k in raw_keywords.split(",") if k.strip()] - if not name or not domain or not keywords: - console.print("[red]专题名称、领域和关键词都不能为空。[/red]") - return - topic = pipeline.create_topic(name=name, domain=domain, keywords=keywords, description="由 /monitor 创建") - console.print("[green]已创建专题[/green]") - console.print(f"ID: [cyan]{topic.get('id')}[/cyan]") + desc = "由 /monitor create 创建" + narrative = (rest or "").strip() or f"{name} {domain} {' '.join(keywords)}" + _finalize_create_topic( + pipeline, + name=name, + domain=domain, + keywords=keywords, + description=desc, + source_label="/monitor create", + narrative_query=narrative, + ) return if command == "status": @@ -112,6 +358,22 @@ def run_monitor_command(raw_query: str | None = None) -> None: console.print(f"报告路径: [cyan]{result['report_path']}[/cyan]") return + if command not in _MONITOR_SUBCOMMANDS: + parsed = _try_parse_natural_create(query) + if parsed: + name, domain, keywords = parsed + desc = f"CLI 自然语言创建|{query[:240]}" + _finalize_create_topic( + pipeline, + name=name, + domain=domain, + keywords=keywords, + description=desc, + source_label="/monitor(自然语言)", + narrative_query=query, + ) + return + console.print(f"[yellow]未知 /monitor 子命令: {command}[/yellow]") _print_help() except Exception as exc: diff --git a/docs/gui_decision.md b/docs/gui_decision.md index 95e93889..7c0abe9c 100644 --- a/docs/gui_decision.md +++ b/docs/gui_decision.md @@ -22,7 +22,7 @@ ## 目录结构(合并后) ``` -streamlit_app.py # 首页:导航 + API 探活 +streamlit_app.py # 仪表盘:API 探活、最近任务、工作流速查 streamlit_legacy_chat.py # 原单文件会话逻辑(供子页引用) pages/ 01_任务状态.py diff --git "a/pages/01_\344\273\273\345\212\241\347\212\266\346\200\201.py" "b/pages/01_\344\273\273\345\212\241\347\212\266\346\200\201.py" index 1264eeed..35076907 100644 --- "a/pages/01_\344\273\273\345\212\241\347\212\266\346\200\201.py" +++ "b/pages/01_\344\273\273\345\212\241\347\212\266\346\200\201.py" @@ -8,7 +8,14 @@ import requests import streamlit as st -from streamlit_ui_theme import inject_ui_theme, page_header, render_nav_sidebar +from streamlit_ui_theme import ( + callout_error, + callout_neutral, + inject_ui_theme, + is_api_reachable, + page_header, + render_nav_sidebar, +) st.set_page_config(page_title="任务状态", page_icon="◼", layout="wide") @@ -62,6 +69,15 @@ def _fetch_tasks() -> List[Dict[str, Any]]: page_header("任务状态", f"API · {API_BASE}") +if not is_api_reachable(API_BASE): + callout_error( + "无法连接 API", + "请先启动:`sona serve --host 127.0.0.1 --port 8765`\n\n" + f"当前探测:`{API_BASE}/health`\n\n" + "若端口不同,请设置环境变量 `API_BASE` 后重启 Streamlit。", + ) + st.stop() + filter_status = st.selectbox("状态筛选", ["全部", "运行中", "已完成", "等待中", "失败"]) c1, c2 = st.columns([1, 6]) @@ -75,7 +91,10 @@ def _fetch_tasks() -> List[Dict[str, Any]]: tasks = _fetch_tasks() if not tasks: - st.info("暂无任务记录。请先 **sona serve** 启动 API,再在「新建任务」提交一次分析。") + callout_neutral( + "暂无任务记录", + "请先在本页或「新建任务」提交一次事件分析;任务列表来自当前 API 进程内存,重启 `sona serve` 后会清空。", + ) st.stop() rev_map = {"运行中": "running", "已完成": "succeeded", "等待中": "queued", "失败": "failed"} diff --git "a/pages/02_\346\226\260\345\273\272\344\273\273\345\212\241.py" "b/pages/02_\346\226\260\345\273\272\344\273\273\345\212\241.py" index 3c167222..360f47f1 100644 --- "a/pages/02_\346\226\260\345\273\272\344\273\273\345\212\241.py" +++ "b/pages/02_\346\226\260\345\273\272\344\273\273\345\212\241.py" @@ -7,7 +7,13 @@ import requests import streamlit as st -from streamlit_ui_theme import inject_ui_theme, page_header, render_nav_sidebar +from streamlit_ui_theme import ( + callout_error, + inject_ui_theme, + is_api_reachable, + page_header, + render_nav_sidebar, +) st.set_page_config(page_title="新建任务", page_icon="◼", layout="wide") @@ -17,7 +23,16 @@ API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8765") page_header("新建分析任务", "调用任务 19:`POST /v1/analyze-event`(同步执行,可能耗时很长)") -st.info("请确认已启动 `sona serve`。") + +if not is_api_reachable(API_BASE): + callout_error( + "API 未连通,无法提交分析", + "请先启动:`sona serve --host 127.0.0.1 --port 8765`\n\n" + f"当前探测:`{API_BASE}/health`", + ) + st.stop() + +st.caption("API 已连通;提交后页面可能长时间无响应,属同步执行正常现象。") with st.form("new_task_form"): event_name = st.text_input("任务名称(可选,会写入分析 query 前缀)", placeholder="例如:315 舆情分析") diff --git "a/pages/03_\346\212\245\345\221\212\346\237\245\347\234\213.py" "b/pages/03_\346\212\245\345\221\212\346\237\245\347\234\213.py" index 689cd013..38d7f297 100644 --- "a/pages/03_\346\212\245\345\221\212\346\237\245\347\234\213.py" +++ "b/pages/03_\346\212\245\345\221\212\346\237\245\347\234\213.py" @@ -7,7 +7,14 @@ import requests import streamlit as st -from streamlit_ui_theme import inject_ui_theme, page_header, render_nav_sidebar +from streamlit_ui_theme import ( + callout_error, + callout_neutral, + inject_ui_theme, + is_api_reachable, + page_header, + render_nav_sidebar, +) st.set_page_config(page_title="报告查看", page_icon="◼", layout="wide") @@ -23,11 +30,21 @@ task_id = str(st.text_input("任务 ID", placeholder="粘贴 UUID") or "").strip() if not task_id: - st.info("请从「任务状态」点「查看报告」,或在「新建任务」完成后自动跳转。") + callout_neutral( + "未选择任务", + "请从「任务状态」点「查看报告」,或在「新建任务」完成后自动跳转;也可在下方粘贴 task_id。", + ) if st.button("前往任务列表"): st.switch_page("pages/01_任务状态.py") st.stop() +if not is_api_reachable(API_BASE): + callout_error( + "API 未连通", + "请先启动 `sona serve`,或检查 `API_BASE` 是否与 API 实际地址一致。", + ) + st.stop() + task_info = None with st.spinner("加载任务…"): try: @@ -38,7 +55,7 @@ st.error(str(exc)) if not task_info: - st.error("无法获取任务详情(API 离线或 task_id 无效)") + callout_error("无法获取任务详情", "API 返回异常或 task_id 无效;请核对 ID 或到「任务状态」重新选择。") st.stop() status = str(task_info.get("status", "")) diff --git "a/pages/99_\347\273\217\345\205\270\344\274\232\350\257\235\344\270\216\350\267\257\347\224\261.py" "b/pages/99_\347\273\217\345\205\270\344\274\232\350\257\235\344\270\216\350\267\257\347\224\261.py" index a1aac973..6e0b0c93 100644 --- "a/pages/99_\347\273\217\345\205\270\344\274\232\350\257\235\344\270\216\350\267\257\347\224\261.py" +++ "b/pages/99_\347\273\217\345\205\270\344\274\232\350\257\235\344\270\216\350\267\257\347\224\261.py" @@ -2,13 +2,21 @@ from __future__ import annotations +import sys +from pathlib import Path + import streamlit as st +st.set_page_config(page_title="经典会话", page_icon="◼", layout="wide") + +# 多页应用下保证项目根在 sys.path(须早于 streamlit_legacy_chat 等包内导入) +_ROOT = Path(__file__).resolve().parents[1] +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) + from streamlit_ui_theme import inject_ui_theme, render_nav_sidebar from streamlit_legacy_chat import run_legacy_chat -st.set_page_config(page_title="经典会话", page_icon="◼", layout="wide") - inject_ui_theme() render_nav_sidebar("legacy") diff --git a/scripts/fetch_student_prs.sh b/scripts/fetch_student_prs.sh old mode 100755 new mode 100644 diff --git a/scripts/map_pr_golden_to_eval_case.py b/scripts/map_pr_golden_to_eval_case.py new file mode 100644 index 00000000..a722ef69 --- /dev/null +++ b/scripts/map_pr_golden_to_eval_case.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +"""Map PR #8-style `eval/golden_cases/*.json` into `tests/evals/cases/*.json` for the Day1 harness. + +The eval runner (`tests/evals/runner.py`) expects each case file to be a JSON object with: + - ``id``, optional ``suite`` / ``suites`` + - ``target`` in ``workflow`` | ``tool`` | ``wiki`` + - optional ``stage`` + - ``input`` object (at minimum ``query`` for wiki/workflow smoke) + - ``fixtures`` with ``mode`` ``live`` | ``replay`` and optional ``recorded_tools`` + - ``expectations`` dict (parsed by ``tests.evals.scorers.core.parse_expectations``) + +Golden files from the PR use a different schema (``case_id``, ``expected_key_points``, ``red_lines``, …). +This script projects them onto a **wiki + replay stub** shape by default so CI-style +``python scripts/eval_runner.py --case --mode replay`` can run without live wiki. + +Examples +-------- +Single case with offline replay fixture (recommended first green path):: + + python scripts/map_pr_golden_to_eval_case.py \\ + eval/golden_cases/case_01_consumption.json \\ + -o tests/evals/cases/golden_pr_case_01_consumption.json \\ + --emit-replay-stub + + EVAL_DETERMINISTIC=1 python scripts/eval_runner.py \\ + --case golden_pr_case_01_consumption --mode replay + +Batch import:: + + python scripts/map_pr_golden_to_eval_case.py \\ + --batch /path/to/eval/golden_cases \\ + --out-dir tests/evals/cases/golden_pr_batch \\ + --emit-replay-stub \\ + --fixture-root tests/fixtures +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + + +def _project_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def _display_path(path: Path, root: Path) -> str: + """Human-readable path for logs (works when ``path`` is outside ``root``).""" + try: + return str(path.resolve().relative_to(root.resolve())) + except ValueError: + return str(path.resolve()) + + +def load_golden(path: Path) -> Dict[str, Any]: + raw = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError(f"Golden file must be a JSON object: {path}") + return raw + + +def _golden_case_id(raw: Dict[str, Any], path: Path) -> str: + cid = str(raw.get("case_id") or path.stem).strip() + if not cid: + raise ValueError(f"Missing case_id and empty stem: {path}") + return cid + + +def _eval_case_id(raw: Dict[str, Any], path: Path, prefix: str) -> str: + base = _golden_case_id(raw, path) + p = prefix.strip() + return f"{p}{base}" if p else base + + +def _join_answer(key_points: Sequence[str], query: str) -> str: + body = "。".join(s.strip() for s in key_points if str(s).strip()) + if not body.endswith(("。", "!", "?", ".")): + body += "。" + lead = f"针对「{query.strip()}」的要点梳理如下:" + return f"{lead}{body}" + + +def _pick_must_contain_any(answer: str, key_points: Sequence[str], max_n: int) -> List[str]: + """Short substrings that appear in ``answer`` (for ``case_example.must_contain_any``).""" + found: List[str] = [] + for pat in ( + r"\d{4}年\d{1,2}月\d{1,2}日", + r"\d{4}年\d{1,2}月", + r"2099年", + r"《[^》]{2,40}》", + ): + for m in re.finditer(pat, answer): + s = m.group(0) + if s and s not in found: + found.append(s) + if len(found) >= max_n: + return found + for kp in key_points: + s = str(kp).strip() + if ":" in s: + tail = s.split(":", 1)[1].strip() + chunk = tail[:24] if len(tail) >= 6 else "" + if chunk and chunk in answer and chunk not in found: + found.append(chunk) + if len(found) >= max_n: + break + return found[:max_n] + + +def _build_stub_sources(answer: str, key_points: Sequence[str]) -> List[Dict[str, Any]]: + k0 = str(key_points[0]).strip() if key_points else answer[:120] + k1 = str(key_points[1]).strip() if len(key_points) > 1 else answer[120:240] + return [ + { + "title": "golden_stub_primary", + "path": "internal://golden_pr_stub/primary", + "snippet": k0[:400] if k0 else answer[:400], + "score": 0.9, + }, + { + "title": "golden_stub_secondary", + "path": "internal://golden_pr_stub/secondary", + "snippet": k1[:400] if k1 else answer[400:800], + "score": 0.85, + }, + ] + + +def build_replay_payload(*, query: str, key_points: Sequence[str]) -> Dict[str, Any]: + answer = _join_answer(key_points, query) + return { + "answer": answer, + "sources": _build_stub_sources(answer, key_points), + } + + +def build_eval_case_dict( + *, + raw: Dict[str, Any], + path: Path, + eval_case_id: str, + suite: str, + extra_suites: Sequence[str], + emit_replay_stub: bool, + recorded_tools_rel: Optional[str], + strict_case_example: bool, + max_latency_ms: Optional[float], +) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]: + """Return (eval_case_json, replay_payload_or_none).""" + query = str(raw.get("query") or "").strip() + if not query: + raise ValueError(f"Golden case missing non-empty 'query': {path}") + + key_points = raw.get("expected_key_points") + key_points_list: List[str] = [str(x).strip() for x in key_points] if isinstance(key_points, list) else [] + + replay_payload: Optional[Dict[str, Any]] = None + fixtures: Dict[str, Any] + if emit_replay_stub: + replay_payload = build_replay_payload(query=query, key_points=key_points_list) + if not recorded_tools_rel: + raise ValueError("emit_replay_stub requires recorded_tools_rel") + fixtures = {"mode": "replay", "recorded_tools": recorded_tools_rel} + else: + fixtures = {"mode": "live"} + + must_contain: List[str] = [] + if strict_case_example and replay_payload is not None: + must_contain = _pick_must_contain_any( + str(replay_payload.get("answer", "")), + key_points_list, + max_n=3, + ) + + thresholds: Dict[str, float] = {} + if replay_payload is not None: + # Stub text aligns snippets with answer → keep a modest bar; omit relevance (warn-only). + thresholds["traceability_score"] = 0.25 + thresholds["structure_completeness"] = 1.0 + else: + thresholds["traceability_score"] = 0.0 + thresholds["structure_completeness"] = 1.0 + + expectations: Dict[str, Any] = { + "required_fields": ["answer", "sources"], + "min_sources": 2, + "min_unique_source_titles": 2, + "required_source_fields": ["title", "snippet"], + "thresholds": thresholds, + "case_example": { + "must_contain_any": must_contain, + "must_not_contain_any": [], + }, + } + if max_latency_ms is not None: + expectations["max_latency_ms"] = max_latency_ms + + suites = [s for s in extra_suites if str(s).strip()] + case: Dict[str, Any] = { + "id": eval_case_id, + "suite": suite, + "suites": suites, + "target": "wiki", + "stage": raw.get("stage"), + "input": { + "query": query, + "options": {"topk": 6, "style": "concise"}, + }, + "fixtures": fixtures, + "expectations": expectations, + "_golden_pr_map": { + "source_file": str(path.as_posix()), + "golden_case_id": _golden_case_id(raw, path), + "golden_target": raw.get("target"), + "golden_mode": raw.get("mode"), + "domain": raw.get("domain"), + "description": raw.get("description"), + "reference_materials": raw.get("reference_materials"), + "rubric": raw.get("rubric"), + "expected_key_points": key_points_list, + "red_lines": raw.get("red_lines") if isinstance(raw.get("red_lines"), list) else [], + }, + } + return case, replay_payload + + +def _iter_batch_paths(directory: Path) -> Iterable[Path]: + for path in sorted(directory.glob("*.json")): + name = path.name + if name.startswith("_"): + continue + if "template" in name.lower(): + continue + if name == "case_demo_scoring.json": + continue + yield path + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("golden", nargs="?", type=Path, help="Path to one golden JSON file.") + parser.add_argument("-o", "--output", type=Path, help="Output eval case JSON path (single mode).") + parser.add_argument("--batch", type=Path, help="Directory of golden JSON files to convert.") + parser.add_argument( + "--out-dir", + type=Path, + help="Output directory for batch mode (one .json per golden).", + ) + parser.add_argument("--id-prefix", default="golden_pr_", help="Prefix for eval case id (default: golden_pr_).") + parser.add_argument("--suite", default="golden-import", help="Primary suite label on the eval case.") + parser.add_argument( + "--extra-suites", + nargs="*", + default=[], + help="Additional suite tags (e.g. ci-gate when you want this in CI).", + ) + parser.add_argument( + "--emit-replay-stub", + action="store_true", + help="Write wiki-shaped replay JSON next to fixtures and point fixtures.recorded_tools to it.", + ) + parser.add_argument( + "--fixture-root", + type=Path, + default=None, + help="Directory under project root for replay stubs (default: /tests/fixtures).", + ) + parser.add_argument( + "--strict-case-example", + action="store_true", + help="Populate expectations.case_example.must_contain_any from golden text (stricter).", + ) + parser.add_argument( + "--max-latency-ms", + type=float, + default=None, + help="Optional expectations.max_latency_ms (omit for replay+EVAL_DETERMINISTIC runs).", + ) + args = parser.parse_args(list(argv) if argv is not None else None) + + root = _project_root() + fixture_root = (args.fixture_root or (root / "tests" / "fixtures")).resolve() + + if args.batch: + if not args.out_dir: + print("--out-dir is required with --batch", file=sys.stderr) + return 2 + out_dir = args.out_dir.resolve() + out_dir.mkdir(parents=True, exist_ok=True) + n_ok = 0 + for gpath in _iter_batch_paths(args.batch.resolve()): + try: + raw = load_golden(gpath) + eid = _eval_case_id(raw, gpath, args.id_prefix) + rel_tools: Optional[str] = None + if args.emit_replay_stub: + stub_dir = fixture_root / eid + stub_dir.mkdir(parents=True, exist_ok=True) + rel_tools = str((stub_dir / "tools.json").relative_to(root)) + case, payload = build_eval_case_dict( + raw=raw, + path=gpath, + eval_case_id=eid, + suite=args.suite, + extra_suites=args.extra_suites, + emit_replay_stub=bool(args.emit_replay_stub), + recorded_tools_rel=rel_tools, + strict_case_example=bool(args.strict_case_example), + max_latency_ms=args.max_latency_ms, + ) + out_path = out_dir / f"{eid}.json" + out_path.write_text(json.dumps(case, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + if payload is not None and rel_tools: + (root / rel_tools).parent.mkdir(parents=True, exist_ok=True) + (root / rel_tools).write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + n_ok += 1 + print(f"OK {gpath.name} -> {_display_path(out_path, root)}") + except Exception as exc: + print(f"SKIP {gpath.name}: {exc}", file=sys.stderr) + print(f"Converted {n_ok} case(s) into {_display_path(out_dir, root)}") + return 0 + + if not args.golden or not args.output: + print("Single mode requires GOLDEN path and -o/--output.", file=sys.stderr) + return 2 + + gpath = args.golden.resolve() + raw = load_golden(gpath) + eid = _eval_case_id(raw, gpath, args.id_prefix) + rel_tools: Optional[str] = None + if args.emit_replay_stub: + stub_dir = fixture_root / eid + stub_dir.mkdir(parents=True, exist_ok=True) + rel_tools = str((stub_dir / "tools.json").relative_to(root)) + + case, payload = build_eval_case_dict( + raw=raw, + path=gpath, + eval_case_id=eid, + suite=args.suite, + extra_suites=args.extra_suites, + emit_replay_stub=bool(args.emit_replay_stub), + recorded_tools_rel=rel_tools, + strict_case_example=bool(args.strict_case_example), + max_latency_ms=args.max_latency_ms, + ) + + out_path = args.output.resolve() + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(case, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + if payload is not None and rel_tools: + tools_path = root / rel_tools + tools_path.parent.mkdir(parents=True, exist_ok=True) + tools_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"Wrote replay stub: {_display_path(tools_path, root)}") + + print(f"Wrote eval case: {_display_path(out_path, root)} (id={eid})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_topic_monitor_tick.py b/scripts/run_topic_monitor_tick.py new file mode 100644 index 00000000..5d2371f0 --- /dev/null +++ b/scripts/run_topic_monitor_tick.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""专题监测定时轮询入口(供 cron / systemd 每 N 小时调用)。 + +默认:列出内存或 Postgres 中的**活跃**专题,对每个专题执行 ``run_monitoring_cycle``。 + +若设置 ``SONA_TOPIC_MONITOR_USE_OPINION_NETINSIGHT=1`` 且已配置 ``SONA_OPINION_SYSTEM_ROOT``、 +``NETINSIGHT_USER`` / ``NETINSIGHT_PASS``,则通过 ``workflow/topic_netinsight_adapter`` 动态加载 +opinion-system 的 ``src.netinsight.client``,按与 opinion-system worker 相同的多平台计数 + +配额拉取 + 去重逻辑采集数据,再写入 Sona 专题监测流水线。 + +用法:: + + cd /path/to/sona-master && python3 scripts/run_topic_monitor_tick.py + +cron(每 6 小时):: + + 0 */6 * * * cd /path/to/sona-master && /usr/bin/python3 scripts/run_topic_monitor_tick.py >> /var/log/sona_topic_monitor.log 2>&1 +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +_ROOT = Path(__file__).resolve().parents[1] +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) + +from workflow.topic_monitoring_pipeline import TopicMonitoringPipeline # noqa: E402 + + +def main() -> int: + parser = argparse.ArgumentParser(description="专题监测定时 tick") + parser.add_argument( + "--topic-id", + action="append", + dest="topic_ids", + default=None, + help="仅处理指定专题 ID(可重复传入);缺省则处理全部活跃专题", + ) + args = parser.parse_args() + + pipeline = TopicMonitoringPipeline() + topics = pipeline.db.list_monitor_topics(is_active=True) + want = {str(x).strip() for x in (args.topic_ids or []) if str(x).strip()} + if want: + ids = [str(t.get("id")) for t in topics if str(t.get("id")) in want] + else: + ids = [str(t.get("id")) for t in topics if t.get("id")] + + if not ids: + print(json.dumps({"ok": True, "message": "无活跃专题,跳过", "processed": []}, ensure_ascii=False)) + return 0 + + from workflow.topic_netinsight_adapter import ( + build_opinion_netinsight_search_func, + topic_monitor_use_opinion_netinsight, + ) + + search_func = None + if topic_monitor_use_opinion_netinsight(): + try: + search_func = build_opinion_netinsight_search_func(pipeline) + except Exception as exc: # noqa: BLE001 + print(json.dumps({"ok": False, "error": f"构建 NetInsight search_func 失败: {exc}"}, ensure_ascii=False)) + return 2 + + out = pipeline.run_monitoring_cycle(ids, search_func=search_func) + print(json.dumps({"ok": True, "topic_ids": ids, "netinsight": bool(search_func), "results": out.get("results", [])}, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/streamlit_app.py b/streamlit_app.py index ea581bac..7c4ffc54 100644 --- a/streamlit_app.py +++ b/streamlit_app.py @@ -1,15 +1,23 @@ -"""Sona 轻量首页:导航到各子页,并探测任务 19 API(与 ``pages/`` 多页 GUI 对齐)。""" +"""Sona 分析员控制台 · 仪表盘:API 状态、最近任务、工作流速查(多页 Streamlit 入口)。""" from __future__ import annotations import os +from typing import Any, Dict, List import requests import streamlit as st -from streamlit_ui_theme import hero_panel, inject_ui_theme, render_nav_sidebar +from streamlit_ui_theme import ( + callout_error, + callout_neutral, + callout_success, + hero_panel, + inject_ui_theme, + render_nav_sidebar, +) -st.set_page_config(page_title="Sona 舆情分析", layout="wide", page_icon="◼") +st.set_page_config(page_title="Sona · 分析员控制台", layout="wide", page_icon="◼") inject_ui_theme() render_nav_sidebar("home") @@ -27,25 +35,112 @@ def api_get(path: str, timeout: float = 3.0) -> dict | list | None: return None +def _fetch_tasks() -> List[Dict[str, Any]]: + data = api_get("/v1/tasks", timeout=5.0) + if not isinstance(data, dict): + return [] + raw = data.get("tasks") + if not isinstance(raw, list): + return [] + out: List[Dict[str, Any]] = [] + for item in raw: + if not isinstance(item, dict): + continue + tid = str(item.get("task_id", "") or "") + status = str(item.get("status", "") or "") + arts = item.get("artifacts") if isinstance(item.get("artifacts"), dict) else {} + report_path = str(arts.get("report_path", "") or "") + name = report_path.split("/")[-1].split("\\")[-1] or (tid[:12] + "…" if len(tid) > 12 else tid) + out.append({"id": tid, "name": name, "status": status}) + return out + + +_STATUS_CN = { + "queued": "等待中", + "running": "运行中", + "succeeded": "已完成", + "failed": "失败", +} + hero_panel( - kicker="Sona · Opinion intelligence", - title="舆情分析工作台", - subtitle="轻量监视与事件分析入口;执行仍由既有 FastAPI 与工作流承担,本页仅做状态展示与路由。", + kicker="Sona · Analyst console", + title="分析员控制台", + subtitle=( + "面向舆情分析的重操作入口:配置与探活、发起事件分析、查看报告与任务、案例与专题、" + "以及经典会话中的 /hot、/wiki 等。界面不替代完整监测平台,与 HTTP API 分工协作。" + ), ) health = api_get("/health") -task_payload = api_get("/v1/tasks", timeout=3.0) -n_tasks = 0 -if isinstance(task_payload, dict) and isinstance(task_payload.get("tasks"), list): - n_tasks = len(task_payload["tasks"]) +tasks = _fetch_tasks() if health else [] + +if not health: + callout_error( + "API 离线:本控制台无法发起「新建任务」或拉取报告", + "请先在本机另开终端启动 HTTP 服务,再刷新本页。\n\n" + f"终端 1:sona serve --host 127.0.0.1 --port 8765\n" + f"当前探测地址:{API_BASE}\n\n" + "若 API 在其他端口,请设置环境变量 API_BASE 后重启 Streamlit。", + ) +else: + callout_success( + "API 在线", + f"已连接 {API_BASE};版本信息:{health.get('version', '—')}", + ) col1, col2, col3 = st.columns(3) -if health: - col1.metric("API", "在线", health.get("version", "—")) +col1.metric("API", "在线" if health else "离线", health.get("version", "—") if health else "sona serve") +col2.metric("本进程任务数", str(len(tasks)), "内存列表,重启 API 会清空") +col3.metric("Streamlit", ":8501", "与 API 可同时运行") + +st.divider() +st.subheader("最近任务(本 API 进程)") +if not health: + callout_neutral("无任务列表", "API 离线时无法获取 /v1/tasks。") +elif not tasks: + callout_neutral( + "暂无任务", + "在「新建任务」提交一次事件分析,或从「经典会话」走完整链路后,此处会列出 task_id 与状态。", + ) else: - col1.metric("API", "离线", "请先 sona serve") -col2.metric("本进程任务数", str(n_tasks), "GET /v1/tasks") -col3.metric("GUI", "Streamlit", "默认 :8501") + preview = tasks[:12] + st.dataframe( + [ + { + "状态": _STATUS_CN.get(r["status"], r["status"]), + "摘要": r["name"][:60] + ("…" if len(r["name"]) > 60 else ""), + "task_id": r["id"], + } + for r in preview + ], + use_container_width=True, + hide_index=True, + ) + if len(tasks) > 12: + st.caption(f"仅展示最近 {len(preview)} 条,共 {len(tasks)} 条;完整列表见「任务状态」。") + +st.divider() +st.subheader("工作流速查") +st.markdown( + """ +| 目标 | 推荐入口 | 说明 | +|------|----------|------| +| **事件分析报告** | 「新建任务」→ 自动跳转「报告查看」 | 调用 `POST /v1/analyze-event`,同步耗时可能很长 | +| **对话里跑 /event、/hot、/wiki** | 侧栏「经典会话」 | 与历史 CLI 会话能力一致 | +| **案例演示检索** | 「案例检索」 | 当前为演示数据,可后续接真实案例库 | +| **专题 YAML** | 「专题配置」 | 写入 `config/topics.yaml` | +""" +) + +with st.expander("环境变量与配置(常用)", expanded=False): + st.markdown( + f""" +- **`API_BASE`**:当前 `{API_BASE}`(Streamlit 探测 API 用) +- **模型与采集**:见项目根目录 **`.env`**、**`config/config.yaml`**;缺 key 时任务会失败 +- **API CORS**:`SONA_API_CORS_ORIGINS`(多源逗号分隔;详见 `docs/api_design.md`) +- **Neo4j(可选)**:`SONA_NEO4J_*`、`SONA_ENABLE_GRAPH_RAG` +""" + ) st.divider() st.subheader("快速入口") @@ -67,6 +162,5 @@ def api_get(path: str, timeout: float = 3.0) -> dict | list | None: st.switch_page("pages/05_专题配置.py") st.info( - "完整对话式事件分析(会话、热点等)请从侧栏进入 **经典会话**。新建分析也可在「新建任务」中调用 " - "`POST /v1/analyze-event`(需 API 在线)。", + "**经典会话**:侧栏进入后可使用 `/hot`、`/wiki`、事件路由等完整能力;与「新建任务」HTTP 路径并行,按习惯二选一即可。" ) diff --git a/streamlit_ui_theme.py b/streamlit_ui_theme.py index e0c9a878..a6d29e2d 100644 --- a/streamlit_ui_theme.py +++ b/streamlit_ui_theme.py @@ -8,6 +8,7 @@ import os from html import escape +import requests import streamlit as st # BettaFish templates/index.html:白底、2px 黑框、按钮反色、硬阴影 @@ -127,13 +128,33 @@ div[data-testid="stDecoration"] { display: none; } footer { visibility: hidden; height: 0; } + + [data-testid="stAlert"] { + border-radius: 0 !important; + border: 2px solid #111111 !important; + } """ _API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8765") + +def get_api_base() -> str: + """当前 Streamlit 使用的 API 基址(与侧栏展示一致)。""" + return _API_BASE + + +def is_api_reachable(base: str | None = None) -> bool: + """探活 GET {base}/health,供子页在表单前提示。""" + root = (base or _API_BASE).rstrip("/") + try: + r = requests.get(f"{root}/health", timeout=2.5) + return r.status_code == 200 + except Exception: + return False + _NAV: list[tuple[str, str, str]] = [ - ("home", "首页", "streamlit_app.py"), + ("home", "仪表盘", "streamlit_app.py"), ("tasks", "任务状态", "pages/01_任务状态.py"), ("new", "新建任务", "pages/02_新建任务.py"), ("report", "报告查看", "pages/03_报告查看.py"), @@ -154,7 +175,7 @@ def render_nav_sidebar(current: str) -> None: st.markdown( '
Sona
' - '
OPINION LAB
', + '
分析员控制台
', unsafe_allow_html=True, ) st.caption(f"API · {escape(_API_BASE)}") @@ -166,7 +187,7 @@ def render_nav_sidebar(current: str) -> None: if st.button(label, key=f"sona_nav_{key}", use_container_width=True): st.switch_page(page) st.divider() - st.caption("界面风格参考 BettaFish「微舆」排版;业务逻辑未改。") + st.caption("面向分析员:重任务走 API + 工作流;本 GUI 不替代完整舆情监测系统。") def hero_panel(*, kicker: str, title: str, subtitle: str) -> None: @@ -203,3 +224,32 @@ def page_header(title: str, caption: str = "") -> None: """, unsafe_allow_html=True, ) + + +def _callout_box(*, border: str, bg: str, shadow: str, title: str, body: str) -> None: + t, b = escape(title), escape(body).replace("\n", "
") + st.markdown( + f""" +
+
{t}
+
{b}
+
+ """, + unsafe_allow_html=True, + ) + + +def callout_error(title: str, body: str) -> None: + """API 离线、任务失败等强提示。""" + _callout_box(border="#8b1538", bg="#fff5f7", shadow="#8b1538", title=title, body=body) + + +def callout_success(title: str, body: str) -> None: + """API 正常等正向提示。""" + _callout_box(border="#1e5f3f", bg="#f4faf6", shadow="#1e5f3f", title=title, body=body) + + +def callout_neutral(title: str, body: str) -> None: + """空列表、说明性提示。""" + _callout_box(border="#444444", bg="#ffffff", shadow="#cccccc", title=title, body=body) diff --git a/tests/conftest.py b/tests/conftest.py index 7482b9f4..0dbb4e40 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,8 @@ # Wiki:默认关闭 LLM 与微博辅助,避免 CI / 无密钥环境调用外网或非确定模型。 os.environ.setdefault("SONA_WIKI_USE_LLM", "0") os.environ.setdefault("SONA_WIKI_WEIBO_AUX", "0") +# 默认不在每次 wiki 检索前跑增量编译(避免 CI 对未编译 expert_notes 反复尝试走模型)。 +os.environ.setdefault("SONA_WIKI_AUTO_COMPILE_ON_QUERY", "0") # Ensure project root is importable when running pytest from any cwd. PROJECT_ROOT = Path(__file__).resolve().parents[1] diff --git a/tests/contracts/test_topic_monitoring_pipeline.py b/tests/contracts/test_topic_monitoring_pipeline.py index e7be20d1..4267191a 100644 --- a/tests/contracts/test_topic_monitoring_pipeline.py +++ b/tests/contracts/test_topic_monitoring_pipeline.py @@ -71,6 +71,9 @@ def list_alerts( def get_linked_cases(self, topic_id: str, min_score: float = 0.5) -> List[Dict[str, Any]]: return [c for c in self.cases if c["topic_id"] == topic_id and c["relevance_score"] >= min_score] + def get_collected_posts(self, topic_id: str, limit: int = 100, since=None) -> List[Dict[str, Any]]: + return [] + def test_generate_periodic_report_writes_markdown(tmp_path: Path) -> None: pipeline = TopicMonitoringPipeline(db=DummyDB()) @@ -95,6 +98,33 @@ def test_monitor_demo_runs_without_external_database(tmp_path: Path, monkeypatch assert Path(result["report"]["report_path"]).exists() +def test_scan_topic_emits_viral_post_alert_for_hot_single_item() -> None: + from workflow.topic_monitoring_pipeline import InMemoryTopicStore, MonitorConfig, TopicMonitoringPipeline + + db = InMemoryTopicStore() + cfg = MonitorConfig(single_post_viral_threshold=100, viral_threshold=50_000) + pipeline = TopicMonitoringPipeline(db=db, config=cfg) + topic = pipeline.create_topic(name="测", domain="综合舆情", keywords=["事故"], description="") + tid = str(topic["id"]) + batch = [ + { + "id": "p-viral-1", + "url": "https://example.com/1", + "platform": "微博", + "author": "a", + "title": "突发", + "content": "内容", + "likes": 80, + "comments": 30, + "shares": 10, + "sentiment": "negative", + } + ] + out = pipeline.scan_topic(tid, batch) + assert out["alerts"] + assert any(str(a.get("alert_type")) == "viral_post" for a in out["alerts"]) + + def test_supabase_config_requires_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("SUPABASE_URL", raising=False) monkeypatch.delenv("SUPABASE_KEY", raising=False) diff --git a/tests/contracts/test_topic_netinsight_adapter.py b/tests/contracts/test_topic_netinsight_adapter.py new file mode 100644 index 00000000..759583a4 --- /dev/null +++ b/tests/contracts/test_topic_netinsight_adapter.py @@ -0,0 +1,28 @@ +"""topic_netinsight_adapter 字段映射单测(不依赖 opinion-system 与 Playwright)。""" + +from __future__ import annotations + +from workflow.topic_netinsight_adapter import opinion_row_to_scan_post + + +def test_opinion_row_to_scan_post_maps_chinese_fields() -> None: + row = { + "原始ID": "abc123", + "URL": "https://example.com/p/1", + "平台": "微博", + "作者": "user1", + "标题": "测试标题", + "内容": "正文", + "点赞数": 10, + "评论数": 3, + "转发数": 1, + "情感": "负面", + } + post = opinion_row_to_scan_post(row) + assert post["id"] == "abc123" + assert post["url"] == "https://example.com/p/1" + assert post["platform"] == "微博" + assert post["likes"] == 10 + assert post["comments"] == 3 + assert post["shares"] == 1 + assert post["sentiment"] == "negative" diff --git a/tests/evals/cases/golden_pr_case_01_consumption.json b/tests/evals/cases/golden_pr_case_01_consumption.json new file mode 100644 index 00000000..6d662dd7 --- /dev/null +++ b/tests/evals/cases/golden_pr_case_01_consumption.json @@ -0,0 +1,73 @@ +{ + "id": "golden_pr_case_01_consumption", + "suite": "golden-import", + "suites": [], + "target": "wiki", + "stage": "day1", + "input": { + "query": "App自动续费乱象及《消费者权益保护法实施条例》规范舆情分析", + "options": { + "topk": 6, + "style": "concise" + } + }, + "fixtures": { + "mode": "replay", + "recorded_tools": "tests/fixtures/golden_pr_case_01_consumption/tools.json" + }, + "expectations": { + "required_fields": [ + "answer", + "sources" + ], + "min_sources": 2, + "min_unique_source_titles": 2, + "required_source_fields": [ + "title", + "snippet" + ], + "thresholds": { + "traceability_score": 0.25, + "structure_completeness": 1.0 + }, + "case_example": { + "must_contain_any": [], + "must_not_contain_any": [] + } + }, + "_golden_pr_map": { + "source_file": "/Users/biaowenhuang/Documents/sona-master/tests/fixtures/golden_pr_source/case_01_consumption.json", + "golden_case_id": "case_01_consumption", + "golden_target": "tool", + "golden_mode": "live", + "domain": "consumption", + "description": "测试工具对'长期社会议题+政策回应'类舆情的分析能力,重点检验政策解读、历史案例关联、执行效果预判、消费者权益保护维度", + "reference_materials": [ + "data/references/consumption/auto_renewal_regulation_202403.txt" + ], + "rubric": { + "completeness": { + "description": "要点覆盖度", + "max_score": 40, + "criteria": "8个要点各5分,部分覆盖2.5分,未覆盖0分" + } + }, + "expected_key_points": [ + "问题背景:App自动续费长期受诟病,消费者'被自动''开通易退出难'", + "典型案例:2020年南宁陈先生案例——15元包月自动续费至2099年,凸显问题荒诞性", + "政策回应:2024年3月《消费者权益保护法实施条例》规定,自动续费前须以显著方式提请消费者注意,7月1日施行", + "法律基础:消费者权益保护法中的知情权、选择权,商家默认自动续费涉嫌侵权", + "执行难点:商家阳奉阴违、退订层层设碍,2021年《网络交易监督管理办法》已提要求但效果不佳", + "深层矛盾:小金额×大用户量=巨额利益,商家趋利本性 vs 消费者权益保护", + "解决路径:商家自律+消费者维权+监管部门联合惩戒(黑名单制度)", + "趋势判断:新规能否终结'稀里糊涂续费',取决于执行力度而非立法本身" + ], + "red_lines": [ + "严禁将事件表述为'2024年新发现问题'(实际是长期积怨,2020年已有极端案例)", + "严禁遗漏'2099年续费'这一关键典型案例(体现问题荒诞性,公众记忆点)", + "严禁将条例施行时间错误表述(2024年7月1日,非3月公布即施行)", + "严禁虚构不存在的'已处罚案例'或'已取消自动续费的App名单'", + "严禁将问题简化为'消费者不注意'(核心是商家侵权,非消费者疏忽)" + ] + } +} diff --git a/tests/fixtures/golden_pr_case_01_consumption/tools.json b/tests/fixtures/golden_pr_case_01_consumption/tools.json new file mode 100644 index 00000000..6e57dad0 --- /dev/null +++ b/tests/fixtures/golden_pr_case_01_consumption/tools.json @@ -0,0 +1,17 @@ +{ + "answer": "针对「App自动续费乱象及《消费者权益保护法实施条例》规范舆情分析」的要点梳理如下:问题背景:App自动续费长期受诟病,消费者'被自动''开通易退出难'。典型案例:2020年南宁陈先生案例——15元包月自动续费至2099年,凸显问题荒诞性。政策回应:2024年3月《消费者权益保护法实施条例》规定,自动续费前须以显著方式提请消费者注意,7月1日施行。法律基础:消费者权益保护法中的知情权、选择权,商家默认自动续费涉嫌侵权。执行难点:商家阳奉阴违、退订层层设碍,2021年《网络交易监督管理办法》已提要求但效果不佳。深层矛盾:小金额×大用户量=巨额利益,商家趋利本性 vs 消费者权益保护。解决路径:商家自律+消费者维权+监管部门联合惩戒(黑名单制度)。趋势判断:新规能否终结'稀里糊涂续费',取决于执行力度而非立法本身。", + "sources": [ + { + "title": "golden_stub_primary", + "path": "internal://golden_pr_stub/primary", + "snippet": "问题背景:App自动续费长期受诟病,消费者'被自动''开通易退出难'", + "score": 0.9 + }, + { + "title": "golden_stub_secondary", + "path": "internal://golden_pr_stub/secondary", + "snippet": "典型案例:2020年南宁陈先生案例——15元包月自动续费至2099年,凸显问题荒诞性", + "score": 0.85 + } + ] +} diff --git a/tests/fixtures/golden_pr_source/case_01_consumption.json b/tests/fixtures/golden_pr_source/case_01_consumption.json new file mode 100644 index 00000000..43bb961b --- /dev/null +++ b/tests/fixtures/golden_pr_source/case_01_consumption.json @@ -0,0 +1,36 @@ +{ + "case_id": "case_01_consumption", + "domain": "consumption", + "query": "App自动续费乱象及《消费者权益保护法实施条例》规范舆情分析", + "description": "测试工具对'长期社会议题+政策回应'类舆情的分析能力,重点检验政策解读、历史案例关联、执行效果预判、消费者权益保护维度", + "expected_key_points": [ + "问题背景:App自动续费长期受诟病,消费者'被自动''开通易退出难'", + "典型案例:2020年南宁陈先生案例——15元包月自动续费至2099年,凸显问题荒诞性", + "政策回应:2024年3月《消费者权益保护法实施条例》规定,自动续费前须以显著方式提请消费者注意,7月1日施行", + "法律基础:消费者权益保护法中的知情权、选择权,商家默认自动续费涉嫌侵权", + "执行难点:商家阳奉阴违、退订层层设碍,2021年《网络交易监督管理办法》已提要求但效果不佳", + "深层矛盾:小金额×大用户量=巨额利益,商家趋利本性 vs 消费者权益保护", + "解决路径:商家自律+消费者维权+监管部门联合惩戒(黑名单制度)", + "趋势判断:新规能否终结'稀里糊涂续费',取决于执行力度而非立法本身" + ], + "red_lines": [ + "严禁将事件表述为'2024年新发现问题'(实际是长期积怨,2020年已有极端案例)", + "严禁遗漏'2099年续费'这一关键典型案例(体现问题荒诞性,公众记忆点)", + "严禁将条例施行时间错误表述(2024年7月1日,非3月公布即施行)", + "严禁虚构不存在的'已处罚案例'或'已取消自动续费的App名单'", + "严禁将问题简化为'消费者不注意'(核心是商家侵权,非消费者疏忽)" + ], + "reference_materials": [ + "data/references/consumption/auto_renewal_regulation_202403.txt" + ], + "rubric": { + "completeness": { + "description": "要点覆盖度", + "max_score": 40, + "criteria": "8个要点各5分,部分覆盖2.5分,未覆盖0分" + } + }, + "target": "tool", + "stage": "day1", + "mode": "live" +} diff --git a/tools/oprag.py b/tools/oprag.py index 58a27827..d06b09e6 100644 --- a/tools/oprag.py +++ b/tools/oprag.py @@ -11,6 +11,7 @@ import hashlib import json +import os import re from datetime import datetime from pathlib import Path @@ -159,6 +160,80 @@ def _iter_theory_expert_note_files() -> List[Path]: return sorted([p for p in THEORY_EXPERT_NOTES_DIR.glob("*.md") if p.is_file()]) +def _iter_expert_note_markdown_files() -> List[Path]: + """ + 列出 ``expert_notes`` 下全部 Markdown(含子目录),供 wiki 增量编译。 + + 与 :func:`_iter_theory_expert_note_files` 不同:后者仅用于 OPRAG 理论槽位召回, + 本函数覆盖根目录笔记(例如培训整理稿),避免新增文件落根目录时漏编译。 + """ + if not EXPERT_NOTES_DIR.exists() or not EXPERT_NOTES_DIR.is_dir(): + return [] + out: List[Path] = [] + seen: set[str] = set() + for p in sorted(EXPERT_NOTES_DIR.rglob("*.md")): + if not p.is_file(): + continue + try: + key = str(p.resolve()) + except Exception: + key = str(p) + if key in seen: + continue + seen.add(key) + out.append(p) + return out + + +def _sort_wiki_sources_pending_first(paths: List[Path], *, force: bool) -> List[Path]: + """尚未生成 ``wiki/sources`` 产物的源文件排在前面,避免 ``limit`` 截断时永远轮不到新稿。""" + if force: + return list(paths) + pending: List[Path] = [] + rest: List[Path] = [] + for fp in paths: + try: + out_path = _wiki_sources_output_path_for_compile(fp) + except Exception: + rest.append(fp) + continue + if out_path.exists(): + rest.append(fp) + else: + pending.append(fp) + return pending + rest + + +def _wiki_auto_compile_enabled() -> bool: + return os.environ.get("SONA_WIKI_AUTO_COMPILE", "1").strip().lower() not in { + "0", + "false", + "no", + "off", + } + + +def _wiki_auto_compile_on_query_enabled() -> bool: + if not _wiki_auto_compile_enabled(): + return False + return os.environ.get("SONA_WIKI_AUTO_COMPILE_ON_QUERY", "1").strip().lower() not in { + "0", + "false", + "no", + "off", + } + + +def _maybe_incremental_wiki_compile(*, limit: int = 200) -> None: + """专家笔记写入或 /wiki 查询前触发的轻量增量编译(默认跳过已有产物)。""" + if not _wiki_auto_compile_enabled(): + return + try: + build_reference_wiki.invoke({"limit": int(limit), "force": False}) + except Exception: + return + + def _iter_reference_files(max_files: int = 200) -> List[Path]: files: List[Path] = [] seen: set[str] = set() @@ -212,11 +287,10 @@ def _wiki_sources_output_path_for_compile(fp: Path) -> Path: except ValueError: rel = None else: - if rel.parts and rel.parts[0] == THEORY_EXPERT_NOTES_DIR.name: - rel_posix = rel.as_posix() - digest = hashlib.sha1(rel_posix.encode("utf-8", errors="replace")).hexdigest()[:10] - slug = _slugify_cn_filename(f"expert_{digest}_{title}", max_len=48) - return WIKI_SOURCES_DIR / f"{slug}.md" + rel_posix = rel.as_posix() + digest = hashlib.sha1(rel_posix.encode("utf-8", errors="replace")).hexdigest()[:10] + slug = _slugify_cn_filename(f"expert_{digest}_{title}", max_len=48) + return WIKI_SOURCES_DIR / f"{slug}.md" is_output_note = WIKI_OUTPUT_DIR.resolve() in fp_res.parents slug = _slugify_cn_filename(f"output_{title}" if is_output_note else title) return WIKI_SOURCES_DIR / f"{slug}.md" @@ -960,16 +1034,24 @@ def build_reference_wiki(limit: int = 30, force: bool = False) -> str: """ 将本地参考资料增量编译为 wiki/sources 页面,并维护 index/log。 - 编译源(按顺序优先处理): + 编译源(未编译条目优先,其次按下列顺序): - - ``expert_notes/舆情分析的相关理论``:理论释义,适合报告「理论研判」引用 - - ``references/raw`` + - ``expert_notes/**/*.md``:含根目录与子目录(含 ``舆情分析的相关理论``),新增笔记落根目录也会被编译 + - ``references/raw``(跳过以 ``readme`` 开头的文件名及以 ``.`` 开头的隐藏文件) - ``references/wiki/output``(不含 ``_candidates``) Args: - limit: 本次最多处理多少个源文件 + limit: 本次最多扫描多少个源文件(含已编译条目的快速跳过) force: 是否强制重编译(默认仅编译尚未生成对应 wiki/sources 产物的文件) + 环境变量(自动增量编译): + + - ``SONA_WIKI_AUTO_COMPILE``:默认 ``1``;设为 ``0``/``false``/``off`` 时, + :func:`append_expert_judgement` 与 :func:`workflow.wiki_cli.answer_wiki_query` + 内置的增量编译钩子不再调用本工具。 + - ``SONA_WIKI_AUTO_COMPILE_ON_QUERY``:默认 ``1``;在总开关为开时,控制是否在每次 + wiki 问答检索前尝试增量编译;关闭后仅在专家研判写入等路径触发(若总开关已关则无效)。 + Returns: JSON 字符串,包含处理统计与输出目录。 """ @@ -983,8 +1065,18 @@ def build_reference_wiki(limit: int = 30, force: bool = False) -> str: max_n = max(1, min(int(limit or 30), 500)) schema_meta = _get_wiki_schema_meta() - theory_note_files = _iter_theory_expert_note_files() - raw_files = [p for p in sorted(RAW_REFERENCES_DIR.rglob("*")) if p.is_file() and p.suffix.lower() in TEXT_SUFFIX] + expert_note_files = _iter_expert_note_markdown_files() + raw_files: List[Path] = [] + for p in sorted(RAW_REFERENCES_DIR.rglob("*")): + if not p.is_file(): + continue + if p.suffix.lower() not in TEXT_SUFFIX: + continue + if p.name.startswith("."): + continue + if p.name.lower().startswith("readme"): + continue + raw_files.append(p) output_files: List[Path] = [] if WIKI_OUTPUT_DIR.exists() and WIKI_OUTPUT_DIR.is_dir(): for p in sorted(WIKI_OUTPUT_DIR.rglob("*")): @@ -996,17 +1088,23 @@ def build_reference_wiki(limit: int = 30, force: bool = False) -> str: continue output_files.append(p) - if not theory_note_files and not raw_files and not output_files: + if not expert_note_files and not raw_files and not output_files: return json.dumps( { "ok": False, - "error": f"无可用编译源: raw 空且无 wiki/output 笔记;理论目录: {THEORY_EXPERT_NOTES_DIR}", + "error": ( + f"无可用编译源: expert_notes 无 md、raw 空且无 wiki/output 笔记;" + f"expert_notes 目录: {EXPERT_NOTES_DIR}" + ), }, ensure_ascii=False, indent=2, ) - all_source_files = theory_note_files + raw_files + output_files + all_source_files = _sort_wiki_sources_pending_first( + expert_note_files + raw_files + output_files, + force=force, + ) processed = [] skipped = [] errors = [] @@ -1037,8 +1135,10 @@ def build_reference_wiki(limit: int = 30, force: bool = False) -> str: except Exception as e: errors.append({"source": str(fp), "error": str(e)}) - page_paths = _iter_wiki_files(max_files=5000) - _upsert_wiki_index(page_paths) + index_dirty = bool(processed or errors or concept_updated_files or entity_updated_files) + if index_dirty or (not WIKI_INDEX.exists()): + page_paths = _iter_wiki_files(max_files=5000) + _upsert_wiki_index(page_paths) return json.dumps( { @@ -1056,7 +1156,8 @@ def build_reference_wiki(limit: int = 30, force: bool = False) -> str: "processed_count": len(processed), "skipped_count": len(skipped), "error_count": len(errors), - "theory_note_source_count": len(theory_note_files), + "expert_note_source_count": len(expert_note_files), + "theory_note_source_count": len(_iter_theory_expert_note_files()), "raw_source_count": len(raw_files), "output_source_count": len(output_files), "concept_updates_count": len(set(concept_updated_files)), @@ -1110,6 +1211,7 @@ def append_expert_judgement(topic: str, judgement: str, tags: str = "", source: try: with open(file_path, "a", encoding="utf-8", errors="replace") as f: f.write("\n".join(block)) + _maybe_incremental_wiki_compile(limit=200) return json.dumps( { "ok": True, diff --git a/utils/hot_time_parser.py b/utils/hot_time_parser.py new file mode 100644 index 00000000..4227ac7f --- /dev/null +++ b/utils/hot_time_parser.py @@ -0,0 +1,84 @@ +"""从用户自然语言中推断热点相关回溯小时数,并写入环境变量供热点流程读取。 + +``streamlit_legacy_chat`` 在跑 /hot 或路由到热点前会调用本模块;若未匹配到显式时间, +则使用 ``SONA_HOT_DEFAULT_LOOKBACK_HOURS``(默认 24)或保持已有 ``SONA_HOT_INFERRED_LOOKBACK_HOURS``。 +""" + +from __future__ import annotations + +import os +import re +from typing import Optional + +_DEFAULT_HOURS = 24 +_MAX_HOURS = 24 * 30 # 约一个月上限,避免误解析出极端值 + + +def _env_int(name: str, default: int, low: int, high: int) -> int: + raw = str(os.environ.get(name, str(default))).strip() + try: + v = int(raw) + except ValueError: + v = default + return max(low, min(high, v)) + + +def infer_hot_lookback_hours(user_input: str) -> int: + """ + 从用户输入中推断「热点/历史快照」相关的小时数。 + + 支持示例:「48小时」「24h」「三天」「一周」「7天」「两周」「最近72小时」。 + 未匹配时返回 ``SONA_HOT_DEFAULT_LOOKBACK_HOURS``(默认 24)。 + """ + text = (user_input or "").strip().lower() + if not text: + return _env_int("SONA_HOT_DEFAULT_LOOKBACK_HOURS", _DEFAULT_HOURS, 1, _MAX_HOURS) + + # 阿拉伯数字 + 小时 / h + m = re.search(r"(\d+)\s*(小时|个小时|h|hr|hrs|hours?)", text, re.I) + if m: + return max(1, min(_MAX_HOURS, int(m.group(1)))) + + m = re.search(r"(\d+)\s*天", text) + if m: + return max(1, min(_MAX_HOURS, int(m.group(1)) * 24)) + + if "两周" in text or "2周" in text or "十四天" in text: + return min(_MAX_HOURS, 14 * 24) + if "一周" in text or "1周" in text or "七天" in text or "7天" in text: + return min(_MAX_HOURS, 7 * 24) + if "三天" in text or "3天" in text: + return min(_MAX_HOURS, 3 * 24) + if "两天" in text or "2天" in text: + return min(_MAX_HOURS, 2 * 24) + + m = re.search(r"最近\s*(\d+)\s*小时", text) + if m: + return max(1, min(_MAX_HOURS, int(m.group(1)))) + + return _env_int("SONA_HOT_DEFAULT_LOOKBACK_HOURS", _DEFAULT_HOURS, 1, _MAX_HOURS) + + +def apply_hot_lookback_hours(hours: int) -> None: + """ + 将推断小时数写入环境变量。 + + - ``SONA_HOT_INFERRED_LOOKBACK_HOURS``:供调试与未来 ``hottopics`` 扩展读取。 + - ``HOT_FALLBACK_LOOKBACK_HOURS``:与 ``tools/hottopics.py`` 中 fallback 逻辑对齐, + 仅在需要拉长历史窗口时使用(取 max(24, hours) 与现有实现一致的下界)。 + """ + h = max(1, min(_MAX_HOURS, int(hours))) + os.environ["SONA_HOT_INFERRED_LOOKBACK_HOURS"] = str(h) + # hottopics 使用 max(24, env),故用户若推断 12h 仍会得到 24 的下限,与现网行为一致 + os.environ["HOT_FALLBACK_LOOKBACK_HOURS"] = str(max(24, h)) + + +def read_applied_lookback_hours() -> Optional[int]: + """读取最近一次 ``apply_hot_lookback_hours`` 写入的推断值(若存在)。""" + raw = os.environ.get("SONA_HOT_INFERRED_LOOKBACK_HOURS", "").strip() + if not raw: + return None + try: + return int(raw) + except ValueError: + return None diff --git a/workflow/topic_monitoring_pipeline.py b/workflow/topic_monitoring_pipeline.py index 0c3e1ac1..52a76309 100644 --- a/workflow/topic_monitoring_pipeline.py +++ b/workflow/topic_monitoring_pipeline.py @@ -1,11 +1,20 @@ """话题监控流水线与周期报告生成。 支持基于 Supabase/Postgres 的专题监控、快照分析、风险告警和日报/周报输出。 + +编排增强(与事件分析的关系): + +- **共用工具**:关键词精炼可复用 ``extract_search_terms``(见 ``workflow/topic_monitoring_workflow``); + 网察侧词表构建与事件分析一致时可复用 ``workflow/netinsight_keywords.build_data_num_search_words``。 +- **专题差异**:滚动增量、多峰值时间线、单帖爆发预警(``viral_post``)与聚合告警并存; + 日报/周报中增加「周期内增量帖子」统计(按 ``collected_at`` 窗口)。 +- **定时**:仓库提供 ``scripts/run_topic_monitor_tick.py`` 供 cron 调用;拉数需注入 ``search_func``。 """ from __future__ import annotations import re +import os import uuid from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone @@ -28,8 +37,12 @@ class MonitorConfig: scan_interval_minutes: int = 60 min_posts_for_trend: int = 10 viral_threshold: int = 1000 + """周期快照维度:总互动量告警阈值(聚合)。""" + single_post_viral_threshold: int = 5000 + """单帖维度:likes+comments+shares 超过即触发 ``viral_post`` 告警。""" alert_cooldown_hours: int = 24 snapshot_interval_hours: int = 6 + netinsight_row_cap_hint: int = 10_000 @dataclass @@ -244,6 +257,21 @@ def get_linked_cases(self, topic_id: str, min_score: float = 0.5) -> List[Dict[s and float(r.get("relevance_score") or 0.0) >= float(min_score) ] + def update_monitor_topic(self, topic_id: str, updates: Dict[str, Any]) -> Dict[str, Any]: + """合并更新专题行(与 SupabaseDB.update_monitor_topic 语义对齐)。""" + row = self.topics.get(str(topic_id)) + if not row: + return {} + for k, v in updates.items(): + if k == "config" and isinstance(v, dict): + base = dict(row.get("config") or {}) + base.update(v) + row["config"] = base + else: + row[k] = v + row["updated_at"] = self._now() + return dict(row) + _DEFAULT_MEMORY_STORE = InMemoryTopicStore() @@ -265,6 +293,24 @@ def __init__( else: self.db = _DEFAULT_MEMORY_STORE self.config = config or MonitorConfig() + raw_v = os.environ.get("SONA_MONITOR_VIRAL_AGG_THRESHOLD") + if raw_v: + try: + self.config.viral_threshold = max(10, int(raw_v)) + except ValueError: + pass + raw_sp = os.environ.get("SONA_MONITOR_VIRAL_POST_THRESHOLD") + if raw_sp: + try: + self.config.single_post_viral_threshold = max(50, int(raw_sp)) + except ValueError: + pass + raw_cap = os.environ.get("SONA_MONITOR_NETINSIGHT_ROW_CAP") + if raw_cap: + try: + self.config.netinsight_row_cap_hint = max(500, int(raw_cap)) + except ValueError: + pass def create_topic( self, @@ -301,11 +347,23 @@ def create_topic( }) return topic + def patch_topic_config(self, topic_id: str, patch: Dict[str, Any]) -> Dict[str, Any]: + """合并写入 ``monitor_topics.config``(内存库 / Postgres 均支持 update_monitor_topic)。""" + topic = self.db.get_topic_by_id(topic_id) + if not topic: + return {} + base = dict(topic.get("config") or {}) + base.update(patch) + if hasattr(self.db, "update_monitor_topic"): + return self.db.update_monitor_topic(topic_id, {"config": base}) + return {} + def scan_topic( self, topic_id: str, search_results: List[Dict[str, Any]], ) -> Dict[str, Any]: + viral_post_alerts: List[Dict[str, Any]] = [] if search_results: posts = [] for item in search_results: @@ -324,10 +382,11 @@ def scan_topic( "metadata": item.get("metadata") if isinstance(item.get("metadata"), dict) else {}, }) self.db.bulk_collect_posts(topic_id, posts) + viral_post_alerts = self._check_single_post_viral_alerts(topic_id, search_results) snapshot = self._generate_snapshot(topic_id) - alerts = self._check_alerts(topic_id, snapshot) - return {"snapshot": snapshot, "alerts": alerts} + agg_alerts = self._check_alerts(topic_id, snapshot) + return {"snapshot": snapshot, "alerts": viral_post_alerts + agg_alerts} def _generate_snapshot( self, @@ -401,6 +460,54 @@ def _extract_keywords(self, text: str, top_n: int = 10) -> List[str]: sorted_words = sorted(freq.items(), key=lambda item: (-item[1], item[0])) return [word for word, _ in sorted_words[:top_n]] + def _check_single_post_viral_alerts( + self, + topic_id: str, + batch: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + """本批新抓数据中:单帖互动爆发预警(与聚合 snapshot 告警互补)。""" + out: List[Dict[str, Any]] = [] + if not batch: + return out + thr = int(self.config.single_post_viral_threshold or 0) + if thr <= 0: + return out + existing = self.db.list_alerts(topic_id=topic_id, is_resolved=False, limit=80) + seen_post_ids: set[str] = set() + for a in existing: + if str(a.get("alert_type") or "") != "viral_post": + continue + meta = a.get("metadata") if isinstance(a.get("metadata"), dict) else {} + pid = str(meta.get("post_id") or "").strip() + if pid: + seen_post_ids.add(pid) + for item in batch: + eng = int(item.get("likes") or 0) + int(item.get("comments") or 0) + int(item.get("shares") or 0) + if eng < thr: + continue + pid = str(item.get("id", "") or "").strip() + if pid and pid in seen_post_ids: + continue + title = str(item.get("title", "") or "")[:120] + out.append( + self.db.create_alert( + topic_id=topic_id, + alert_type="viral_post", + title="单帖互动量异常偏高", + message=f"单帖总互动 {eng}(阈值 {thr})。标题摘要:{title}", + severity="warning", + metadata={ + "post_id": pid, + "url": str(item.get("url", "") or ""), + "platform": str(item.get("platform", "") or ""), + "engagement": eng, + }, + ) + ) + if pid: + seen_post_ids.add(pid) + return out + def _check_alerts(self, topic_id: str, snapshot: Dict[str, Any]) -> List[Dict[str, Any]]: alerts: List[Dict[str, Any]] = [] existing = self.db.list_alerts(topic_id=topic_id, is_resolved=False, limit=50) @@ -506,7 +613,31 @@ def generate_periodic_report( now = _utcnow() report_path = output_dir / f"{self._safe_filename(topic.get('name','topic'))}_{period_label}_{now.strftime('%Y%m%d_%H%M%S')}.md" - content = self._build_report_markdown(topic, snapshots, alerts, cases, period_label) + p = str(period or "").lower() + if p in ("daily", "day"): + since = now - timedelta(days=1) + window_note = "最近 24 小时" + elif p in ("weekly", "week"): + since = now - timedelta(days=7) + window_note = "最近 7 天" + else: + since = now - timedelta(days=1) + window_note = "最近 24 小时(默认)" + + tid = str(topic.get("id") or "") + period_posts: List[Dict[str, Any]] = [] + if tid and hasattr(self.db, "get_collected_posts"): + period_posts = self.db.get_collected_posts(tid, limit=8000, since=since) + + content = self._build_report_markdown( + topic, + snapshots, + alerts, + cases, + period_label, + period_posts=period_posts, + period_window_note=window_note, + ) report_path.write_text(content, encoding="utf-8") return { "topic_id": topic_id, @@ -527,12 +658,40 @@ def _build_report_markdown( alerts: List[Dict[str, Any]], cases: List[Dict[str, Any]], period_label: str, + *, + period_posts: Optional[List[Dict[str, Any]]] = None, + period_window_note: str = "", ) -> str: title = topic.get("name", "专题") lines: List[str] = [f"# {title} {period_label}报告", "", f"生成时间:{_utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}", ""] lines.append(f"- 专题领域:{topic.get('domain', '')}") lines.append(f"- 话题描述:{topic.get('description', '')}") lines.append(f"- 关键词:{', '.join(str(k.get('keyword') or '') for k in self.db.get_topic_keywords(topic.get('id')) if str(k.get('keyword') or '').strip())}") + lines.append( + "- 分析维度:与事件分析对齐(声量结构、情感、平台分布、关键节点等);" + "专题下可出现**多个时间峰值/多条子事件线**,需结合快照序列阅读。" + ) + cfg = topic.get("config") if isinstance(topic.get("config"), dict) else {} + if cfg: + lines.append( + f"- 监测配置:间隔≈{cfg.get('collect_interval_hours', '—')}h;" + f"NetInsight 条数提示上限≈{cfg.get('netinsight_max_rows_hint', '—')}。" + ) + lines.append("") + + posts = period_posts or [] + note = period_window_note or "本周期" + lines.extend([f"## {period_label}数据增量({note})", ""]) + if posts: + eng = sum( + int(p.get("likes") or 0) + int(p.get("comments") or 0) + int(p.get("shares") or 0) + for p in posts + ) + lines.append(f"- 窗口内抓取帖数:**{len(posts)}**") + lines.append(f"- 窗口内总互动(粗):**{eng}**") + lines.append("- 说明:增量口径按 ``collected_at`` 落在窗口内统计;跨周期去重需在入库层按 post_id/url 归并。") + else: + lines.append("- 本窗口内暂无增量帖子(或未注入外部采集 / 本轮未拉到新数据)。") lines.append("") if snapshots: diff --git a/workflow/topic_monitoring_workflow.py b/workflow/topic_monitoring_workflow.py new file mode 100644 index 00000000..f5e26bfd --- /dev/null +++ b/workflow/topic_monitoring_workflow.py @@ -0,0 +1,214 @@ +"""专题监测编排:与事件分析共用工具,独立工作流。 + +与 ``event_analysis_pipeline`` 的差异(体会): + +- **时间结构**:事件分析围绕单一事件窗口;专题监测是滚动时间轴,下面会有**多条声量脊线 / 多个子事件节点**。 +- **数据形态**:专题数据是**增量追加 + 去重归并**(同一帖多平台、多次抓取),不同于一次性全量 CSV 跑完即分析。 +- **调度**:默认每 ``collect_interval_hours``(如 6h)拉一次增量;常驻定时由 **cron / systemd / K8s CronJob** 调用 + ``scripts/run_topic_monitor_tick.py``(或自建调度)——本库不内置系统级守护进程。 +- **NetInsight**:单专题/单次拉取常见上限约 **1 万条**;若预估窗口内总量逼近上限,应**缩短间隔**、**收窄检索式**或**分页按时间切片**(见 ``suggest_interval_for_netinsight``)。 +- **工具复用**:关键词阶段与事件分析一致,可调用 ``extract_search_terms``;词表 / 普通-or-高级模式与 ``build_data_num_search_words`` 对齐,便于后续接 ``data_num`` / ``data_collect``。 +- **opinion-system 桥接**:若本地克隆了 ``opinion-system``,可用 ``workflow/topic_netinsight_adapter`` 在运行时加载其 ``src.netinsight.client``,将 opinion-system worker 的多平台计数 + 配额拉取 + 去重接到 ``run_monitoring_cycle`` 的 ``search_func``(见 ``SONA_TOPIC_MONITOR_USE_OPINION_NETINSIGHT``)。 + +环境变量(可选): + +- ``SONA_MONITOR_SKIP_EXTRACT``:设为 ``1`` 跳过 ``extract_search_terms``(离线/无密钥时)。 +- ``SONA_TOPIC_MONITOR_INTERVAL_HOURS``:默认采集间隔建议(写入专题 config)。 +- ``SONA_TOPIC_MONITOR_USE_OPINION_NETINSIGHT``:``1`` 时 ``run_topic_monitor_tick`` 使用 opinion-system NetInsight 客户端拉数(需 ``SONA_OPINION_SYSTEM_ROOT`` + 网察账号)。 +- ``SONA_OPINION_SYSTEM_ROOT``:opinion-system 仓库根路径。 +- ``SONA_TOPIC_MONITOR_NETINSIGHT_WINDOW_HOURS``:单次拉取时间窗(小时),默认 ``24``。 + +from __future__ import annotations + +import json +import os +from typing import Any, Dict, List, Optional, Sequence + +from tools.extract_search_terms import extract_search_terms +from workflow.netinsight_keywords import NETINSIGHT_PLATFORMS + + +def _parse_tool_json(raw: str) -> Dict[str, Any]: + t = (raw or "").strip() + if not t: + return {} + try: + out = json.loads(t) + except json.JSONDecodeError: + return {"_parse_error": "invalid_json", "raw_preview": t[:400]} + return out if isinstance(out, dict) else {"_parse_error": "not_object", "value": out} + + +def invoke_extract_search_terms(*, query: str) -> Dict[str, Any]: + """调用与事件分析 Step1 相同的 ``extract_search_terms``,返回 dict。""" + raw = extract_search_terms.invoke({"query": query}) + if not isinstance(raw, str): + raw = str(raw) + return _parse_tool_json(raw) + + +def _normalize_search_words(plan: Dict[str, Any]) -> List[str]: + sw = plan.get("searchWords") + if sw is None: + sw = plan.get("search_words") + if isinstance(sw, str) and sw.strip(): + return [sw.strip()] + if isinstance(sw, list): + return [str(x).strip() for x in sw if str(x).strip()] + return [] + + +def dedupe_keywords(items: Sequence[str], *, max_items: int = 24) -> List[str]: + out: List[str] = [] + seen: set[str] = set() + for it in items: + s = str(it or "").strip() + if len(s) < 2: + continue + key = s.lower() + if key in seen: + continue + seen.add(key) + out.append(s) + if len(out) >= max_items: + break + return out + + +def merge_seed_with_extract_plan(*, seed_keywords: List[str], plan: Dict[str, Any]) -> List[str]: + """种子词 + extract_search_terms 产出的 searchWords 合并(种子优先)。""" + extracted = _normalize_search_words(plan) + return dedupe_keywords(list(seed_keywords) + extracted) + + +def refine_monitor_keywords( + *, + user_text: str, + seed_keywords: List[str], +) -> Dict[str, Any]: + """ + 专题创建前:用自然语言 + 种子词走一遍 extract,得到更稳的监测词表。 + + Returns: + ``merged_keywords``, ``search_plan``(可能含 error 字段), ``used_extract`` 等。 + """ + seeds = [str(x).strip() for x in (seed_keywords or []) if str(x).strip()] + q = str(user_text or "").strip() + if not q: + q = "、".join(seeds) if seeds else "舆情专题监测" + plan: Dict[str, Any] = {} + err: Optional[str] = None + try: + plan = invoke_extract_search_terms(query=q) + if plan.get("_parse_error"): + err = str(plan.get("_parse_error")) + except Exception as exc: # noqa: BLE001 + err = str(exc) + plan = {"error": err} + + merged = merge_seed_with_extract_plan(seed_keywords=seeds, plan=plan) if not err else dedupe_keywords(seeds) + if not merged: + merged = dedupe_keywords(seeds) or ["舆情"] + + return { + "search_plan": plan, + "merged_keywords": merged, + "used_extract": not bool(err), + "extract_error": err, + } + + +def _env_float(name: str, default: float) -> float: + raw = str(os.environ.get(name, "") or "").strip() + if not raw: + return default + try: + return float(raw) + except ValueError: + return default + + +def _env_int(name: str, default: int) -> int: + raw = str(os.environ.get(name, "") or "").strip() + if not raw: + return default + try: + return int(raw) + except ValueError: + return default + + +def build_default_topic_config( + *, + merged_search_plan: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """写入 ``monitor_topics.config`` 的默认工作流元数据(不含密钥)。""" + interval = _env_float("SONA_TOPIC_MONITOR_INTERVAL_HOURS", 6.0) + cap = _env_int("SONA_MONITOR_NETINSIGHT_ROW_CAP", 10_000) + viral_post = _env_int("SONA_MONITOR_VIRAL_POST_THRESHOLD", 5000) + viral_agg = _env_int("SONA_MONITOR_VIRAL_AGG_THRESHOLD", 1000) + return { + "workflow": "topic_monitoring_v1", + "rolling_monitoring": True, + "monitoring_started_at": None, + "platforms": "ALL", + "platform_list": list(NETINSIGHT_PLATFORMS), + "collect_interval_hours": max(0.5, min(interval, 168.0)), + "netinsight_max_rows_hint": max(1000, min(cap, 50_000)), + "analysis_profile": "event_similar_multi_peak", + "data_notes": ( + "专题数据建议:按 collected_at 增量入库;跨平台去重可按 content_hash / url 归并;" + "大流量下优先时间切片拉取,避免单次超过平台条数上限。" + ), + "alert_thresholds": { + "single_post_engagement": viral_post, + "aggregate_engagement_snapshot": viral_agg, + }, + "extract_search_plan_snapshot": merged_search_plan or {}, + } + + +def suggest_interval_for_netinsight( + *, + estimated_rows_in_window: int, + row_cap: int = 10_000, + desired_coverage: float = 0.85, +) -> Dict[str, Any]: + """ + 在 NetInsight 单次约 ``row_cap`` 条上限下,粗算建议采集间隔(小时)。 + + 假设:每个间隔窗口内新增帖子近似均匀,希望单次拉取不超过 ``row_cap * desired_coverage``。 + """ + cap = max(500, int(row_cap)) + est = max(0, int(estimated_rows_in_window)) + if est <= 0: + return { + "suggested_interval_hours": _env_float("SONA_TOPIC_MONITOR_INTERVAL_HOURS", 6.0), + "rationale": "无预估条数,使用默认间隔。", + } + per_hour = est / max(_env_float("SONA_TOPIC_MONITOR_INTERVAL_HOURS", 6.0), 0.5) + if per_hour <= 0: + return {"suggested_interval_hours": 6.0, "rationale": "无法估算速率,使用默认 6h。"} + budget = cap * max(0.1, min(desired_coverage, 0.99)) + hours = max(0.5, budget / per_hour) + hours = min(hours, 168.0) + return { + "suggested_interval_hours": round(hours, 2), + "rationale": ( + f"按预估窗口内 {est} 条、折算约 {per_hour:.1f} 条/小时," + f"为使单次拉取低于约 {int(budget)} 条,建议间隔 ≥ {hours:.2f} 小时(需结合实际 API 行为调参)。" + ), + } + + +def format_monitor_workflow_hints(config: Dict[str, Any]) -> str: + """给人看的调度与 NetInsight 提示(Rich 外层可自行加颜色)。""" + interval = config.get("collect_interval_hours", 6) + cap = config.get("netinsight_max_rows_hint", 10_000) + lines = [ + f"- 默认全平台列表已写入 config(共 {len(config.get('platform_list') or [])} 项,采集侧仍可用 ALL)。", + f"- 建议采集间隔:每 **{interval}** 小时(可用环境变量 SONA_TOPIC_MONITOR_INTERVAL_HOURS 调整)。", + f"- NetInsight 单次拉取常见上限约 **{cap}** 条:大流量专题请缩短间隔或拆分时间窗。", + "- 定时执行示例:``0 */6 * * * cd /path/to/sona-master && python3 scripts/run_topic_monitor_tick.py``", + ] + return "\n".join(lines) diff --git a/workflow/topic_netinsight_adapter.py b/workflow/topic_netinsight_adapter.py new file mode 100644 index 00000000..5fb3690e --- /dev/null +++ b/workflow/topic_netinsight_adapter.py @@ -0,0 +1,336 @@ +"""专题监测 NetInsight 采集桥接:复用 opinion-system 的 NetInsight 工作流。 + +`opinion-system`(`/Users/biaowenhuang/Documents/opinion-system`)中的 +``backend/src/netinsight/client.py`` 与 ``worker.py`` 实现了与 Sona 同源的 +网察登录、分平台计数、按比例配额与列表拉取、正文去重等逻辑。 +本模块**不拷贝**大段实现,而是在运行时把 ``opinion-system/backend`` 加入 +``sys.path`` 后 import 其 ``src.netinsight.client``,再把其 +``normalize_record`` 产出行映射为 ``TopicMonitoringPipeline.scan_topic`` 所需结构。 + +环境变量: + +- ``SONA_OPINION_SYSTEM_ROOT``:opinion-system 仓库根目录;未设置时默认使用 + 本机常见路径 ``/Users/biaowenhuang/Documents/opinion-system``(其他机器请显式配置)。 +- ``SONA_TOPIC_MONITOR_USE_OPINION_NETINSIGHT``:设为 ``1``/``true`` 时, + ``scripts/run_topic_monitor_tick.py`` 会对活跃专题注入本模块提供的 ``search_func``。 +- 网察账号与 Sona 现有工具一致:``NETINSIGHT_USER`` / ``NETINSIGHT_PASS``(及 + ``NETINSIGHT_HEADLESS``、``NETINSIGHT_NO_PROXY`` 等,见 opinion-system 与 Sona README)。 +- ``SONA_TOPIC_MONITOR_NETINSIGHT_WINDOW_HOURS``:单次拉取时间窗(小时),默认 ``24``。 +""" + +from __future__ import annotations + +import logging +import os +import sys +import uuid +from datetime import timedelta +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Tuple + +from workflow.netinsight_keywords import NETINSIGHT_PLATFORMS +from workflow.topic_monitoring_pipeline import TopicMonitoringPipeline, _utcnow + +LOGGER = logging.getLogger(__name__) + +SearchFunc = Callable[[List[str], str, int], List[Dict[str, Any]]] + +_CACHED_CTX: Any = None +_CACHED_USER: str = "" + + +def opinion_system_root() -> Path: + raw = os.environ.get("SONA_OPINION_SYSTEM_ROOT", "/Users/biaowenhuang/Documents/opinion-system") + return Path(raw).expanduser().resolve() + + +def _bootstrap_opinion_import_path(root: Path) -> None: + backend = root / "backend" + src = backend / "src" + if not (src / "netinsight" / "client.py").is_file(): + raise FileNotFoundError(f"未找到 opinion-system NetInsight 客户端: {src / 'netinsight' / 'client.py'}") + for p in (str(backend), str(src)): + if p not in sys.path: + sys.path.insert(0, p) + + +def load_opinion_netinsight_client() -> Any: + """加载 ``src.netinsight.client``(来自 opinion-system)。""" + root = opinion_system_root() + _bootstrap_opinion_import_path(root) + import importlib + + return importlib.import_module("src.netinsight.client") + + +def _sona_netinsight_credentials() -> Tuple[str, str]: + user = str(os.environ.get("NETINSIGHT_USER") or os.environ.get("NETINSIGHT_USERNAME") or "").strip() + password = str(os.environ.get("NETINSIGHT_PASS") or os.environ.get("NETINSIGHT_PASSWORD") or "").strip() + return user, password + + +def format_time_range_hours(hours_back: float) -> str: + end = _utcnow() + start = end - timedelta(hours=max(0.25, float(hours_back))) + fmt = "%Y-%m-%d %H:%M:%S" + return f"{start.strftime(fmt)};{end.strftime(fmt)}" + + +def opinion_row_to_scan_post(row: Dict[str, Any]) -> Dict[str, Any]: + """将 opinion-system ``normalize_record`` 的中文字段行转为 ``scan_topic`` 帖子字典。""" + pid = str(row.get("原始ID") or row.get("id") or "").strip() + if not pid: + pid = f"ni-{uuid.uuid4().hex[:12]}" + title = str(row.get("标题") or "") + content = str(row.get("内容") or "") + em = str(row.get("情感") or "").strip().lower() + if any(x in em for x in ("负", "消极", "neg")): + sentiment = "negative" + elif any(x in em for x in ("正", "积极", "pos")): + sentiment = "positive" + else: + sentiment = "neutral" + return { + "id": pid, + "url": str(row.get("URL") or ""), + "platform": str(row.get("平台") or "unknown"), + "author": str(row.get("作者") or ""), + "title": title, + "content": content, + "likes": int(row.get("点赞数") or 0), + "comments": int(row.get("评论数") or 0), + "shares": int(row.get("转发数") or 0), + "sentiment": sentiment, + "tags": [], + "metadata": { + "source": "opinion-system-netinsight", + "检索词": row.get("检索词"), + "发布时间": row.get("发布时间"), + }, + } + + +def _is_login_expired(exc: BaseException) -> bool: + return "515" in str(exc) or "登录" in str(exc) + + +def _get_or_login_context(client: Any) -> Any: + global _CACHED_CTX, _CACHED_USER + user, password = _sona_netinsight_credentials() + if not user or not password: + raise RuntimeError("未配置 NETINSIGHT_USER / NETINSIGHT_PASS,无法使用 opinion-system NetInsight 采集。") + if _CACHED_CTX is not None and _CACHED_USER == user: + return _CACHED_CTX + + headless = str(os.environ.get("NETINSIGHT_HEADLESS", "true")).strip().lower() in ("1", "true", "yes") + no_proxy = str(os.environ.get("SONA_NETINSIGHT_NO_PROXY", os.environ.get("NETINSIGHT_NO_PROXY", ""))).strip().lower() in ( + "1", + "true", + "yes", + ) + browser_channel = str(os.environ.get("NETINSIGHT_BROWSER_CHANNEL", "") or "").strip() + + LOGGER.info("NetInsight login via opinion-system client (user=%s)", user[:3] + "***") + ctx = client.login_and_capture( + user, + password, + headless=headless, + no_proxy=no_proxy, + browser_channel=browser_channel, + ) + _CACHED_CTX = ctx + _CACHED_USER = user + return ctx + + +def collect_posts_via_opinion_system( + *, + keyword_list: List[str], + topic_id: str, + platforms: List[str], + time_range: str, + total_limit: int, + page_size: int = 50, + sort: str = "comments_desc", + info_type: str = "2", + allocate_by_platform: bool = False, +) -> List[Dict[str, Any]]: + """ + 执行一轮与 opinion-system worker 等价的多平台计数 + 拉取 + 去重,并映射为 Sona 帖子结构。 + """ + global _CACHED_CTX + client = load_opinion_netinsight_client() + keywords = [str(x).strip() for x in keyword_list if str(x).strip()] + if not keywords: + return [] + + context = _get_or_login_context(client) + per_platform_limit = max(1, int(total_limit) // max(len(platforms), 1)) + + aggregated_plan: Dict[str, Any] = {} + all_warnings: List[str] = [] + planned_total = 0 + + for platform in platforms: + try: + result = client.query_platform_counts( + keywords=keywords, + time_range=time_range, + platform=platform, + threshold=per_platform_limit, + context=context, + progress_callback=None, + ) + except Exception as exc: # noqa: BLE001 + if _is_login_expired(exc): + _CACHED_CTX = None + context = _get_or_login_context(client) + result = client.query_platform_counts( + keywords=keywords, + time_range=time_range, + platform=platform, + threshold=per_platform_limit, + context=context, + progress_callback=None, + ) + else: + LOGGER.warning("NetInsight 计数失败 platform=%s err=%s", platform, exc) + continue + aggregated_plan[platform] = result + planned_total += int(result.get("planned_total") or 0) + all_warnings.extend(result.get("warnings") or []) + + if allocate_by_platform and len(platforms) > 1: + platform_totals = { + str(p): int((aggregated_plan.get(p) or {}).get("total_available") or 0) for p in platforms + } + platform_limits = client.allocate_platform_limits(platform_totals, int(total_limit)) + planned_total = 0 + for platform in platforms: + plan = aggregated_plan.get(platform) or {} + raw_counts = plan.get("raw_counts") or {} + platform_limit = max(0, int(platform_limits.get(platform) or 0)) + search_matrix = client.allocate_platform_limits(raw_counts, platform_limit) + plan["search_matrix"] = search_matrix + plan["planned_total"] = sum(search_matrix.values()) + aggregated_plan[platform] = plan + planned_total += int(plan["planned_total"] or 0) + + if planned_total <= 0: + LOGGER.warning("NetInsight 无可采数据 topic=%s warnings=%s", topic_id, all_warnings[:3]) + return [] + + all_records: List[Dict[str, Any]] = [] + for platform in platforms: + platform_plan = aggregated_plan.get(platform) or {} + search_matrix = platform_plan.get("search_matrix") or {} + if not search_matrix: + continue + try: + result = client.collect_platform_records( + search_matrix=search_matrix, + time_range=time_range, + platform=platform, + context=context, + page_size=page_size, + sort=sort, + info_type=info_type, + task_id=str(topic_id), + progress_callback=None, + ) + except Exception as exc: # noqa: BLE001 + if _is_login_expired(exc): + _CACHED_CTX = None + context = _get_or_login_context(client) + result = client.collect_platform_records( + search_matrix=search_matrix, + time_range=time_range, + platform=platform, + context=context, + page_size=page_size, + sort=sort, + info_type=info_type, + task_id=str(topic_id), + progress_callback=None, + ) + else: + LOGGER.warning("NetInsight 拉取失败 platform=%s err=%s", platform, exc) + continue + all_records.extend(result.get("records") or []) + + deduped, removed = client.deduplicate_records(all_records) + LOGGER.info( + "NetInsight opinion bridge topic=%s raw=%s deduped=%s removed=%s", + topic_id, + len(all_records), + len(deduped), + removed, + ) + return [opinion_row_to_scan_post(r) for r in deduped] + + +def build_opinion_netinsight_search_func( + pipeline: TopicMonitoringPipeline, + *, + window_hours: Optional[float] = None, + total_limit: Optional[int] = None, + allocate_by_platform: bool = False, +) -> SearchFunc: + """ + 构造 ``TopicMonitoringPipeline.run_monitoring_cycle`` 所需的 ``search_func``。 + + 每次调用按专题 ``config`` 中的 ``platform_list``(缺省为全平台列表)与 + ``netinsight_max_rows_hint``(缺省 8000)拉取最近 ``window_hours`` 小时数据。 + """ + + def _search_func(keyword_list: List[str], topic_id: str, cycle_idx: int) -> List[Dict[str, Any]]: # noqa: ARG001 + topic = pipeline.db.get_topic_by_id(topic_id) or {} + cfg = topic.get("config") if isinstance(topic.get("config"), dict) else {} + pl = cfg.get("platform_list") + if isinstance(pl, list) and pl: + platforms = [str(x).strip() for x in pl if str(x).strip()] + else: + platforms = list(NETINSIGHT_PLATFORMS) + lim = int(total_limit or cfg.get("netinsight_max_rows_hint") or 8000) + lim = max(100, min(lim, 50_000)) + if window_hours is not None: + wh = float(window_hours) + else: + env_w = str(os.environ.get("SONA_TOPIC_MONITOR_NETINSIGHT_WINDOW_HOURS", "") or "").strip() + if env_w: + wh = float(env_w) + else: + cwh = cfg.get("netinsight_pull_window_hours") + wh = float(cwh) if cwh not in (None, "") else 24.0 + try: + wh = float(wh) + except (TypeError, ValueError): + wh = 24.0 + wh = max(1.0, min(wh, 720.0)) + tr = format_time_range_hours(wh) + try: + return collect_posts_via_opinion_system( + keyword_list=keyword_list, + topic_id=topic_id, + platforms=platforms, + time_range=tr, + total_limit=lim, + allocate_by_platform=allocate_by_platform, + ) + except FileNotFoundError as exc: + LOGGER.error("%s", exc) + return [] + except Exception as exc: # noqa: BLE001 + LOGGER.exception("opinion-system NetInsight 采集异常 topic=%s", topic_id) + raise + + return _search_func + + +def topic_monitor_use_opinion_netinsight() -> bool: + return str(os.environ.get("SONA_TOPIC_MONITOR_USE_OPINION_NETINSIGHT", "") or "").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) diff --git a/workflow/wiki_cli.py b/workflow/wiki_cli.py index 1420e716..4be462f4 100644 --- a/workflow/wiki_cli.py +++ b/workflow/wiki_cli.py @@ -1561,6 +1561,13 @@ def answer_wiki_query( get_env_config() except Exception: pass + try: + from tools.oprag import _maybe_incremental_wiki_compile, _wiki_auto_compile_on_query_enabled + + if _wiki_auto_compile_on_query_enabled(): + _maybe_incremental_wiki_compile(limit=200) + except Exception: + pass root = (project_root or Path(__file__).resolve().parents[1]).resolve() normalized_query = _normalize_query(query) # Dynamic topk: event overview / entity-heavy queries benefit from a wider recall set. From a991726166238fd12071845c75006b8fdaedb436 Mon Sep 17 00:00:00 2001 From: izukuuuu Date: Tue, 26 May 2026 14:54:01 +0800 Subject: [PATCH 02/12] =?UTF-8?q?=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 10 +- README.md | 40 +- agent/reactagent.py | 71 +- api/agent_run_store.py | 85 + api/event_runner.py | 36 +- api/report_utils.py | 171 +- api/schema.py | 263 +- api/server.py | 1062 +- api/task_store.py | 4 + cli/event_analysis_workflow.py | 6 +- cli/router.py | 8 + docs/frontend_next.md | 56 + frontend/.env.example | 1 + frontend/app/api/sona/[...path]/route.ts | 55 + frontend/app/globals.css | 1135 ++ frontend/app/layout.tsx | 27 + frontend/app/page.tsx | 10 + frontend/app/providers.tsx | 37 + frontend/eslint.config.mjs | 6 + .../features/workspace/SonaChatAnswer.tsx | 48 + .../features/workspace/SonaChatComposer.tsx | 169 + .../features/workspace/SonaChatThread.tsx | 146 + frontend/features/workspace/SonaMarkdown.tsx | 25 + .../features/workspace/SonaReportFileCard.tsx | 86 + frontend/features/workspace/SonaSidebar.tsx | 298 + frontend/features/workspace/SonaWorkspace.tsx | 897 + frontend/features/workspace/chatMessageUi.tsx | 12 + .../features/workspace/conversationTurns.ts | 642 + .../workspace/mergeSessionMessages.test.ts | 145 + .../workspace/mergeSessionMessages.ts | 73 + .../features/workspace/reportRefs.test.ts | 47 + frontend/features/workspace/reportRefs.ts | 96 + .../workspace/sessionIdentity.test.ts | 61 + .../features/workspace/sessionIdentity.ts | 110 + frontend/features/workspace/sonaToolUi.ts | 28 + frontend/features/workspace/useChatSession.ts | 264 + frontend/next-env.d.ts | 6 + frontend/next.config.ts | 8 + frontend/package-lock.json | 14392 ++++++++++++++++ frontend/package.json | 38 + frontend/services/sonaApi.ts | 191 + frontend/stores/appStore.ts | 120 + frontend/tsconfig.json | 41 + frontend/tsconfig.tsbuildinfo | 1 + frontend/types/conversation.ts | 49 + frontend/types/sona.ts | 132 + frontend/vitest.config.ts | 14 + ...60\345\273\272\344\273\273\345\212\241.py" | 17 +- tests/contracts/test_frontend_api.py | 347 + ...46\227\245\346\212\245_20260515_092032.md" | 35 + ...46\227\245\346\212\245_20260515_092529.md" | 35 + ...46\227\245\346\212\245_20260515_133812.md" | 35 + ...46\227\245\346\212\245_20260515_133856.md" | 35 + ...46\227\245\346\212\245_20260515_134304.md" | 35 + ...46\227\245\346\212\245_20260515_134326.md" | 35 + ...46\227\245\346\212\245_20260515_134747.md" | 35 + ...46\227\245\346\212\245_20260515_135652.md" | 35 + ...46\227\245\346\212\245_20260515_135818.md" | 35 + ...46\227\245\346\212\245_20260515_141553.md" | 35 + ...46\227\245\346\212\245_20260516_051030.md" | 35 + ...46\227\245\346\212\245_20260516_051154.md" | 35 + ...46\227\245\346\212\245_20260516_051220.md" | 35 + ...46\227\245\346\212\245_20260516_051320.md" | 35 + ...46\227\245\346\212\245_20260515_092324.md" | 46 + ...46\227\245\346\212\245_20260515_132630.md" | 46 + utils/session_manager.py | 30 +- workflow/event_analysis_pipeline.py | 168 +- workflow/progress.py | 28 + workflow/runner.py | 4 + 69 files changed, 22328 insertions(+), 70 deletions(-) create mode 100644 api/agent_run_store.py create mode 100644 docs/frontend_next.md create mode 100644 frontend/.env.example create mode 100644 frontend/app/api/sona/[...path]/route.ts create mode 100644 frontend/app/globals.css create mode 100644 frontend/app/layout.tsx create mode 100644 frontend/app/page.tsx create mode 100644 frontend/app/providers.tsx create mode 100644 frontend/eslint.config.mjs create mode 100644 frontend/features/workspace/SonaChatAnswer.tsx create mode 100644 frontend/features/workspace/SonaChatComposer.tsx create mode 100644 frontend/features/workspace/SonaChatThread.tsx create mode 100644 frontend/features/workspace/SonaMarkdown.tsx create mode 100644 frontend/features/workspace/SonaReportFileCard.tsx create mode 100644 frontend/features/workspace/SonaSidebar.tsx create mode 100644 frontend/features/workspace/SonaWorkspace.tsx create mode 100644 frontend/features/workspace/chatMessageUi.tsx create mode 100644 frontend/features/workspace/conversationTurns.ts create mode 100644 frontend/features/workspace/mergeSessionMessages.test.ts create mode 100644 frontend/features/workspace/mergeSessionMessages.ts create mode 100644 frontend/features/workspace/reportRefs.test.ts create mode 100644 frontend/features/workspace/reportRefs.ts create mode 100644 frontend/features/workspace/sessionIdentity.test.ts create mode 100644 frontend/features/workspace/sessionIdentity.ts create mode 100644 frontend/features/workspace/sonaToolUi.ts create mode 100644 frontend/features/workspace/useChatSession.ts create mode 100644 frontend/next-env.d.ts create mode 100644 frontend/next.config.ts create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/services/sonaApi.ts create mode 100644 frontend/stores/appStore.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.tsbuildinfo create mode 100644 frontend/types/conversation.ts create mode 100644 frontend/types/sona.ts create mode 100644 frontend/vitest.config.ts create mode 100644 tests/contracts/test_frontend_api.py create mode 100644 "topic_monitoring_reports/\346\265\213\350\257\225\344\270\223\351\242\230_\346\227\245\346\212\245_20260515_092032.md" create mode 100644 "topic_monitoring_reports/\346\265\213\350\257\225\344\270\223\351\242\230_\346\227\245\346\212\245_20260515_092529.md" create mode 100644 "topic_monitoring_reports/\346\265\213\350\257\225\344\270\223\351\242\230_\346\227\245\346\212\245_20260515_133812.md" create mode 100644 "topic_monitoring_reports/\346\265\213\350\257\225\344\270\223\351\242\230_\346\227\245\346\212\245_20260515_133856.md" create mode 100644 "topic_monitoring_reports/\346\265\213\350\257\225\344\270\223\351\242\230_\346\227\245\346\212\245_20260515_134304.md" create mode 100644 "topic_monitoring_reports/\346\265\213\350\257\225\344\270\223\351\242\230_\346\227\245\346\212\245_20260515_134326.md" create mode 100644 "topic_monitoring_reports/\346\265\213\350\257\225\344\270\223\351\242\230_\346\227\245\346\212\245_20260515_134747.md" create mode 100644 "topic_monitoring_reports/\346\265\213\350\257\225\344\270\223\351\242\230_\346\227\245\346\212\245_20260515_135652.md" create mode 100644 "topic_monitoring_reports/\346\265\213\350\257\225\344\270\223\351\242\230_\346\227\245\346\212\245_20260515_135818.md" create mode 100644 "topic_monitoring_reports/\346\265\213\350\257\225\344\270\223\351\242\230_\346\227\245\346\212\245_20260515_141553.md" create mode 100644 "topic_monitoring_reports/\346\265\213\350\257\225\344\270\223\351\242\230_\346\227\245\346\212\245_20260516_051030.md" create mode 100644 "topic_monitoring_reports/\346\265\213\350\257\225\344\270\223\351\242\230_\346\227\245\346\212\245_20260516_051154.md" create mode 100644 "topic_monitoring_reports/\346\265\213\350\257\225\344\270\223\351\242\230_\346\227\245\346\212\245_20260516_051220.md" create mode 100644 "topic_monitoring_reports/\346\265\213\350\257\225\344\270\223\351\242\230_\346\227\245\346\212\245_20260516_051320.md" create mode 100644 "topic_monitoring_reports/\351\253\230\351\223\201\350\210\206\346\203\205_\346\227\245\346\212\245_20260515_092324.md" create mode 100644 "topic_monitoring_reports/\351\253\230\351\223\201\350\210\206\346\203\205_\346\227\245\346\212\245_20260515_132630.md" create mode 100644 workflow/progress.py diff --git a/.gitignore b/.gitignore index 2999857e..3c27d380 100644 --- a/.gitignore +++ b/.gitignore @@ -76,6 +76,14 @@ logs/ *.log *.log.* +# Frontend / Node +node_modules/ +.next/ +out/ +frontend/node_modules/ +frontend/.next/ +frontend/out/ + # Eval artifacts eval_results/ @@ -116,4 +124,4 @@ Desktop.ini opinion_analysis_kb.zip # 舆情知识库目录(体积大、本地维护;已从 Git 索引移除,不再推送到远端) -opinion_analysis_kb/ \ No newline at end of file +opinion_analysis_kb/ diff --git a/README.md b/README.md index aa6a51c6..753160f5 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ - **🧠 垂类知识库增强**:支持控烟、健康、交通、大熊猫等领域包,并通过 `workflow/domain_routing.json` 自动注入优先证据 - **🕸️ Graph RAG 可选增强**:通过 Neo4j Aura/本地 Neo4j 召回相似案例、理论框架和处置经验;连接失败时自动降级 - **📚 案例库与专题监测**:完整报告可自动沉淀到 `opinion_analysis_kb/references/wiki/cases/`,并提供 `/case` 相似案例检索与 `/monitor` 专题快照/日报周报演示 -- **🧩 HTTP API 与轻量 GUI**:`sona serve` 提供 FastAPI;`streamlit_app.py` 提供 **分析员控制台**(仪表盘 + 多页任务/报告/经典会话) +- **🧩 HTTP API 与轻量 GUI**:`sona serve` 提供 FastAPI;`frontend/` 提供 Next.js 分析工作台;`streamlit_app.py` 保留多页控制台 - **📊 报告质量增强**:时间线证据/影响标签、情绪结构、四阶段行动清单、热点风险分级与案例候选输出 ### 支持的模型提供商 @@ -382,10 +382,39 @@ sona serve --host 127.0.0.1 --port 8765 # 探活 curl http://127.0.0.1:8765/health -# 终端 2:启动 Streamlit 多页 GUI(需安装 streamlit) +# 终端 2A:启动 Next.js 前端(推荐) +npm --prefix frontend install +npm --prefix frontend run dev + +# 终端 2B:或启动 Streamlit 多页 GUI(保留入口) streamlit run streamlit_app.py ``` +Next.js 前端访问地址:`http://127.0.0.1:3000`。 + +注意:`server.py` / `api.server:app` 不会由前端自动启动;必须先运行 `sona serve`。Next.js 只通过 BFF 代理访问后端 API。若后端端口不是 `8765`,在 `frontend/.env.local` 设置: + +```env +SONA_API_BASE=http://127.0.0.1:8765 +``` + +#### Next.js 分析工作台是什么 + +`frontend/` 是 React + Next.js App Router 前端,页面只访问 `/api/sona/...`,由 Next.js Route Handler 代理到 `sona serve` 的 FastAPI。它支持: + +| 能力 | 对应 API | +|------|----------| +| 会话创建 / 恢复 | `POST /v1/chat/sessions`、`GET /v1/chat/sessions` | +| 普通对话流式输出 | `POST /v1/chat/sessions/{task_id}/messages:stream` | +| `/event` 事件分析 | `POST /v1/analyze-event` | +| `/wiki` / `/wiki-approve` | `POST /v1/wiki/query`、`POST /v1/wiki/approve` | +| `/case` 案例检索 | `POST /v1/cases/search` | +| `/hot` 热点态势 | `POST /v1/hot/run` | +| `/monitor` 专题监测 | `GET/POST /v1/monitor/...` | +| `/models` / `/tools` | `GET /v1/models`、`GET /v1/tools` | + +`/set`、`/compress`、`/clear`、`/exit` 属于 CLI 管理或生命周期指令,前端 v1 不在聊天输入中执行。 + #### 分析员控制台(Streamlit)是什么 多页 Streamlit **不是**「第二个舆情监测大屏」,而是给分析员用的 **控制台**: @@ -404,6 +433,7 @@ UI 采用与 BettaFish「微舆」类似的 **高对比、硬边框** 风格; | 变量 | 说明 | |------|------| | `API_BASE` | Streamlit 访问的 API 根地址,默认 `http://127.0.0.1:8765` | +| `SONA_API_BASE` | Next.js BFF 访问的 FastAPI 根地址,默认 `http://127.0.0.1:8765` | | `SONA_API_CORS_ORIGINS` | API 的 CORS 白名单(见 `docs/api_design.md`) | | `.env` 内模型与采集 Key | 与 CLI 相同;缺省则事件分析或采集会失败 | @@ -412,11 +442,15 @@ UI 采用与 BettaFish「微舆」类似的 **高对比、硬边框** 风格; 主要 API: - `GET /health`:服务探活 +- `POST /v1/chat/sessions`:创建前端会话 +- `POST /v1/chat/sessions/{task_id}/messages:stream`:流式对话与自动路由 - `POST /v1/analyze-event`:同步执行一次事件分析,返回 `task_id` 与报告路径 +- `POST /v1/wiki/query`、`POST /v1/cases/search`:知识库问答与案例检索 +- `POST /v1/hot/run`、`GET/POST /v1/monitor/...`:热点与专题监测 - `GET /v1/tasks`:查看当前 API 进程内存中的任务 - `GET /v1/tasks/{task_id}/report`:返回 HTML 报告 -更多约定见 `docs/api_design.md` 和 `docs/gui_decision.md`。 +更多约定见 `docs/api_design.md`、`docs/gui_decision.md` 和 `docs/frontend_next.md`。 **控制台 / 新建任务常见问题** diff --git a/agent/reactagent.py b/agent/reactagent.py index 0163ba95..46c9d774 100644 --- a/agent/reactagent.py +++ b/agent/reactagent.py @@ -233,30 +233,65 @@ def _stream_mode_flow( existing_data_path = opts.get("existing_data_path") skip_data_collect = bool(opts.get("skip_data_collect", False)) force_fresh_start = opts.get("force_fresh_start") - yield {"type": "tool_call", "tool_name": "full_report_mode_node", "args": {"query": user_input}, "run_id": f"mode_full_{task_id or 'na'}"} - report_length = str(opts.get("report_length") or "").strip() or None - file_url_or_path = run_full_report_mode( - user_query=user_input, - task_id=task_id or "", - session_manager=session_manager, - debug=True, - existing_data_path=existing_data_path, - skip_data_collect=skip_data_collect, - force_fresh_start=force_fresh_start, - report_length=report_length, - ) + progress_queue: queue.Queue = queue.Queue() + result_holder: Dict[str, Any] = {"value": None, "error": None} + + def on_workflow_progress(event: Dict[str, Any]) -> Any: + hook = opts.get("_web_progress_hook") + hook_result = None + if callable(hook): + hook_result = hook(event) + progress_queue.put(("step", dict(event))) + return hook_result + + def run_pipeline() -> None: + try: + report_length = str(opts.get("report_length") or "").strip() or None + result_holder["value"] = run_full_report_mode( + user_query=user_input, + task_id=task_id or "", + session_manager=session_manager, + debug=True, + existing_data_path=existing_data_path, + skip_data_collect=skip_data_collect, + force_fresh_start=force_fresh_start, + report_length=report_length, + progress_callback=on_workflow_progress, + ) + except Exception as exc: # noqa: BLE001 + result_holder["error"] = exc + finally: + progress_queue.put(("done", None)) + + yield { + "type": "tool_call", + "tool_name": "full_report_mode_node", + "args": {"query": user_input}, + "run_id": f"mode_full_{task_id or 'na'}", + } + worker = threading.Thread(target=run_pipeline, daemon=True) + worker.start() + while True: + try: + kind, payload = progress_queue.get(timeout=0.3) + except queue.Empty: + if not worker.is_alive(): + break + continue + if kind == "step": + yield {"type": "workflow_step", **payload} + elif kind == "done": + break + worker.join() + if result_holder["error"] is not None: + raise result_holder["error"] + file_url_or_path = result_holder["value"] yield { "type": "tool_result", "tool_name": "full_report_mode_node", "result": str(file_url_or_path or ""), "run_id": f"mode_full_{task_id or 'na'}", } - final_text = ( - "完整舆情报告流程已完成。\n" - f"- 报告地址:{str(file_url_or_path or '未返回')}\n" - "- 已复用事件分析工作流节点(采集/分析/报告生成)。" - ) - yield {"type": "message", "message": AIMessage(content=final_text), "message_id": f"mode_full_msg_{task_id or 'na'}"} return # 创建带消息历史的 Agent diff --git a/api/agent_run_store.py b/api/agent_run_store.py new file mode 100644 index 00000000..4fe35df3 --- /dev/null +++ b/api/agent_run_store.py @@ -0,0 +1,85 @@ +"""In-memory Agent run registry for the frontend run/pause/resume API.""" + +from __future__ import annotations + +import threading +import uuid +from datetime import datetime +from typing import Any, Dict, List, Optional + +from api.schema import AgentEventType, AgentRunEnvelope, AgentRunEvent, AgentRunStatus + + +class AgentRunRecord: + """Mutable server-side state for one Agent run.""" + + def __init__(self, *, task_id: str, query: str, options: Dict[str, Any]) -> None: + self.run_id = str(uuid.uuid4()) + self.task_id = task_id + self.turn_id = str(uuid.uuid4()) + self.query = query + self.options = dict(options) + self.status: str = AgentRunStatus.QUEUED + self.events: List[AgentRunEvent] = [] + self.started = False + self.finished = False + self.approval_condition = threading.Condition() + self.pending_approval_id = "" + self.approval_decision: Optional[Dict[str, Any]] = None + + def envelope(self) -> AgentRunEnvelope: + return AgentRunEnvelope( + run_id=self.run_id, + task_id=self.task_id, + turn_id=self.turn_id, + status=self.status, + query=self.query, + events=list(self.events), + ) + + def add_event( + self, + event_type: AgentEventType | str, + *, + status: str = "", + title: str = "", + detail: str = "", + payload: Optional[Dict[str, Any]] = None, + ) -> AgentRunEvent: + event = AgentRunEvent( + event_id=str(uuid.uuid4()), + run_id=self.run_id, + task_id=self.task_id, + turn_id=self.turn_id, + event_type=str(event_type), + status=status, + title=title, + detail=detail, + payload=payload or {}, + created_at=datetime.now().isoformat(), + ) + self.events.append(event) + return event + + +class AgentRunStore: + def __init__(self) -> None: + self._lock = threading.Lock() + self._runs: Dict[str, AgentRunRecord] = {} + + def create(self, *, task_id: str, query: str, options: Dict[str, Any]) -> AgentRunRecord: + record = AgentRunRecord(task_id=task_id, query=query, options=options) + with self._lock: + self._runs[record.run_id] = record + return record + + def get(self, run_id: str) -> Optional[AgentRunRecord]: + with self._lock: + return self._runs.get(run_id) + + +_store = AgentRunStore() + + +def get_agent_run_store() -> AgentRunStore: + return _store diff --git a/api/event_runner.py b/api/event_runner.py index 5269152f..c0230894 100644 --- a/api/event_runner.py +++ b/api/event_runner.py @@ -3,13 +3,17 @@ from __future__ import annotations import asyncio +import logging import os import sys +import traceback +from pathlib import Path from cli.event_analysis_workflow import run_event_analysis_workflow from cli.router import route_query from utils.path import ensure_task_dirs, get_task_dir from utils.session_manager import get_session_manager +from workflow.telemetry import append_ndjson_log from api.report_utils import extract_report_html_path from api.schema import ( @@ -21,6 +25,10 @@ TaskStatus, ) +_ROOT = Path(__file__).resolve().parents[1] +LOG_PATH = os.getenv("SONA_DEBUG_LOG_PATH", str(_ROOT / ".cursor" / "debug.log")) +LOGGER = logging.getLogger(__name__) + def run_analyze_event(body: AnalyzeEventRequest) -> TaskEnvelope: """ @@ -65,11 +73,34 @@ def run_analyze_event(body: AnalyzeEventRequest) -> TaskEnvelope: skip_data_collect=skip_data_collect, ) except Exception as exc: # noqa: BLE001 — surface to API client + error_message = str(exc) + LOGGER.exception("analyze-event workflow failed task_id=%s: %s", task_id, error_message) + try: + Path(LOG_PATH).parent.mkdir(parents=True, exist_ok=True) + except Exception: + pass + append_ndjson_log( + log_path=LOG_PATH, + run_id=task_id, + hypothesis_id="API_EVENT_WORKFLOW_FAILED", + location="api/event_runner.py:run_analyze_event", + message="POST /v1/analyze-event 工作流失败", + data={ + "task_id": task_id, + "query": body.query, + "error_type": type(exc).__name__, + "error_message": error_message, + "traceback": traceback.format_exc(), + }, + ) return TaskEnvelope( task_id=task_id, status=TaskStatus.FAILED, - artifacts=TaskArtifacts(sandbox_dir=str(get_task_dir(task_id))), - error=ApiError(error_code=ERROR_WORKFLOW, error_message=str(exc)), + artifacts=TaskArtifacts( + trace_path=LOG_PATH, + sandbox_dir=str(get_task_dir(task_id)), + ), + error=ApiError(error_code=ERROR_WORKFLOW, error_message=error_message), ) session_data = manager.load_session(task_id) or {} @@ -77,6 +108,7 @@ def run_analyze_event(body: AnalyzeEventRequest) -> TaskEnvelope: stm_file = manager.stm_dir / f"{task_id}.json" artifacts = TaskArtifacts( report_path=report_path, + trace_path=LOG_PATH, sandbox_dir=str(get_task_dir(task_id)), session_hint=str(stm_file), ) diff --git a/api/report_utils.py b/api/report_utils.py index 14c942c5..2e0cdc56 100644 --- a/api/report_utils.py +++ b/api/report_utils.py @@ -3,20 +3,110 @@ from __future__ import annotations import json -from typing import Any, Dict +import os +import re +from pathlib import Path +from typing import Any, Dict, Optional +from urllib.parse import unquote, urlparse + +from api.schema import ( + ERROR_WORKFLOW, + ApiError, + TaskArtifacts, + TaskEnvelope, + TaskStatus, +) +from utils.path import get_task_dir +from utils.session_manager import get_session_manager + +_ROOT = Path(__file__).resolve().parents[1] +_DEFAULT_TRACE_PATH = os.getenv("SONA_DEBUG_LOG_PATH", str(_ROOT / ".cursor" / "debug.log")) +_FILE_URL_RE = re.compile(r"(file://[^\s)\]]+)", re.IGNORECASE) +_REPORT_PATH_RE = re.compile( + r"([A-Za-z]:[\\/][^\s)\]]+|(?:\.?[\\/]|sandbox[\\/])?[A-Za-z0-9_.\-\u4e00-\u9fff\\/]+\.html?)", + re.IGNORECASE, +) + + +def _file_url_to_path(value: str) -> str: + parsed = urlparse(value) + if parsed.scheme.lower() != "file": + return value + path = unquote(parsed.path or "") + if parsed.netloc and parsed.netloc.lower() not in ("localhost", ""): + path = f"//{parsed.netloc}{path}" + if re.match(r"^/[A-Za-z]:/", path): + path = path[1:] + return path.replace("/", os.sep) + + +def _normalize_report_path(value: Any) -> str: + text = str(value or "").strip().strip("\"'") + if not text: + return "" + if text.lower().startswith("file://"): + return _file_url_to_path(text) + return text + + +def _extract_report_path_from_text(value: str) -> str: + text = str(value or "").strip() + if not text: + return "" + match = _FILE_URL_RE.search(text) + if match: + return _normalize_report_path(match.group(1)) + match = _REPORT_PATH_RE.search(text) + if not match: + return "" + return _normalize_report_path(match.group(1)) + + +def _localize_sandbox_report_path(task_id: str, raw_path: str) -> str: + path_text = _normalize_report_path(raw_path) + if not task_id or not path_text: + return path_text + path_obj = Path(path_text).expanduser() + if path_obj.is_file(): + return str(path_obj) + + normalized = path_text.replace("\\", "/") + marker = f"/sandbox/{task_id}/" + index = normalized.lower().find(marker.lower()) + if index >= 0: + tail = normalized[index + len(marker) :] + local_path = _ROOT / "sandbox" / task_id / Path(*tail.split("/")) + if local_path.is_file(): + return str(local_path) + return path_text + + +def _find_latest_report_in_task_sandbox(task_id: str) -> str: + if not task_id: + return "" + task_dir = get_task_dir(task_id) + if not task_dir.exists(): + return "" + reports = [path for path in task_dir.rglob("*.html") if path.is_file()] + if not reports: + return "" + reports.sort(key=lambda path: path.stat().st_mtime, reverse=True) + return str(reports[0]) def extract_report_html_path(session_data: Dict[str, Any]) -> str: """ - Parse the latest ``report_html`` tool message and return filesystem path. + Parse the latest report-producing message and return filesystem path. - Mirrors the logic used by ``streamlit_app._extract_report_info``. + Streamed full-report runs persist the wrapper tool ``full_report_mode_node`` + rather than the inner ``report_html`` tool, so support both shapes. """ messages = session_data.get("messages", []) for msg in reversed(messages): if msg.get("role") != "tool": continue - if msg.get("tool_name") != "report_html": + tool_name = msg.get("tool_name") + if tool_name not in ("report_html", "full_report_mode_node"): continue raw = str(msg.get("content", "") or "").strip() if not raw: @@ -24,10 +114,77 @@ def extract_report_html_path(session_data: Dict[str, Any]) -> str: try: data = json.loads(raw) except Exception: + data = None + if isinstance(data, dict): + html_path = _normalize_report_path(data.get("html_file_path") or data.get("report_path") or data.get("file_url")) + if html_path: + return html_path + else: + html_path = _extract_report_path_from_text(raw) + if html_path: + return html_path + for msg in reversed(messages): + if msg.get("role") != "assistant": continue - if not isinstance(data, dict): - continue - html_path = str(data.get("html_file_path", "") or "").strip() + html_path = _extract_report_path_from_text(str(msg.get("content", "") or "")) if html_path: return html_path return "" + + +def build_task_envelope_from_session( + task_id: str, + *, + failed: bool = False, + error_message: str = "", +) -> TaskEnvelope: + """Build a TaskEnvelope from persisted chat/session messages.""" + manager = get_session_manager() + session_data = manager.load_session(task_id) or {} + report_path = _localize_sandbox_report_path(task_id, extract_report_html_path(session_data)) + if not report_path or not Path(report_path).expanduser().is_file(): + report_path = _find_latest_report_in_task_sandbox(task_id) or report_path + stm_file = manager.stm_dir / f"{task_id}.json" + artifacts = TaskArtifacts( + report_path=report_path, + trace_path=_DEFAULT_TRACE_PATH, + sandbox_dir=str(get_task_dir(task_id)), + session_hint=str(stm_file), + ) + if failed: + return TaskEnvelope( + task_id=task_id, + status=TaskStatus.FAILED, + artifacts=artifacts, + error=ApiError( + error_code=ERROR_WORKFLOW, + error_message=error_message or "Chat workflow failed", + ), + ) + return TaskEnvelope( + task_id=task_id, + status=TaskStatus.SUCCEEDED, + artifacts=artifacts, + error=None, + ) + + +def resolve_report_path_for_task(task_id: str) -> Optional[str]: + """Resolve HTML report path from task store or persisted session.""" + from api.task_store import get_task_store + + env = get_task_store().get(task_id) + if env is not None: + raw_path = (env.artifacts.report_path or "").strip() + localized = _localize_sandbox_report_path(task_id, raw_path) + if localized and Path(localized).expanduser().is_file(): + return localized + + session_data = get_session_manager().load_session(task_id) or {} + session_path = _localize_sandbox_report_path(task_id, extract_report_html_path(session_data)) + if session_path and Path(session_path).expanduser().is_file(): + return session_path + sandbox_path = _find_latest_report_in_task_sandbox(task_id) + if sandbox_path: + return sandbox_path + return session_path or None diff --git a/api/schema.py b/api/schema.py index 4759b5d8..ce61ce98 100644 --- a/api/schema.py +++ b/api/schema.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import StrEnum -from typing import Any, List, Optional +from typing import Any, Dict, List, Optional from pydantic import BaseModel, ConfigDict, Field @@ -17,6 +17,34 @@ class TaskStatus(StrEnum): FAILED = "failed" +class AgentRunStatus(StrEnum): + """Lifecycle of a frontend Agent run.""" + + QUEUED = "queued" + RUNNING = "running" + WAITING_APPROVAL = "waiting_approval" + SUCCEEDED = "succeeded" + FAILED = "failed" + ABORTED = "aborted" + + +class AgentEventType(StrEnum): + """Canonical frontend Agent event names.""" + + AGENT_MESSAGE_DELTA = "agent_message_delta" + AGENT_STEP_STARTED = "agent_step_started" + AGENT_STEP_UPDATED = "agent_step_updated" + AGENT_STEP_COMPLETED = "agent_step_completed" + TOOL_CALL_STARTED = "tool_call_started" + TOOL_CALL_COMPLETED = "tool_call_completed" + TOOL_CALL_FAILED = "tool_call_failed" + APPROVAL_REQUESTED = "approval_requested" + APPROVAL_RESOLVED = "approval_resolved" + ARTIFACT_CREATED = "artifact_created" + RUN_COMPLETED = "run_completed" + RUN_FAILED = "run_failed" + + class ApiError(BaseModel): """Structured error returned to API clients.""" @@ -61,6 +89,239 @@ class AnalyzeEventRequest(BaseModel): disable_blocking_prompts: bool = Field(default=False) +class SessionCreateRequest(BaseModel): + """Create a chat/session container for frontend use.""" + + model_config = ConfigDict(extra="forbid") + + initial_query: str = Field(default="Frontend session", min_length=1) + + +class SessionUpdateRequest(BaseModel): + """Update frontend chat session metadata.""" + + model_config = ConfigDict(extra="forbid") + + description: Optional[str] = Field(default=None, min_length=1, max_length=120) + + +class SessionEnvelope(BaseModel): + """Serialized session data returned to frontend clients.""" + + model_config = ConfigDict(extra="allow") + + task_id: str + created_at: str = "" + updated_at: str = "" + description: str = "" + initial_query: str = "" + messages: List[Dict[str, Any]] = Field(default_factory=list) + token_usage: Dict[str, Any] = Field(default_factory=dict) + + +class SessionListResponse(BaseModel): + """GET /v1/chat/sessions payload.""" + + model_config = ConfigDict(extra="forbid") + + sessions: List[SessionEnvelope] = Field(default_factory=list) + + +class ChatMessageRequest(BaseModel): + """Request body for streaming a message through the chat runtime.""" + + model_config = ConfigDict(extra="forbid") + + query: str = Field(..., min_length=1) + auto_route: bool = Field(default=True) + prefer_existing_data: bool = Field(default=True) + workflow_options: Dict[str, Any] = Field(default_factory=dict) + + +class AgentRunCreateRequest(BaseModel): + """Create a web Agent run for a chat session.""" + + model_config = ConfigDict(extra="forbid") + + query: str = Field(..., min_length=1) + auto_route: bool = Field(default=True) + prefer_existing_data: bool = Field(default=True) + workflow_options: Dict[str, Any] = Field(default_factory=dict) + + +class AgentRunEvent(BaseModel): + """Persisted frontend Agent event.""" + + model_config = ConfigDict(extra="allow") + + event_id: str + run_id: str + task_id: str + turn_id: str + event_type: AgentEventType | str + status: str = "" + title: str = "" + detail: str = "" + payload: Dict[str, Any] = Field(default_factory=dict) + created_at: str = "" + + +class AgentRunEnvelope(BaseModel): + """Frontend Agent run state.""" + + model_config = ConfigDict(extra="allow") + + run_id: str + task_id: str + turn_id: str + status: AgentRunStatus | str + query: str = "" + events: List[AgentRunEvent] = Field(default_factory=list) + + +class AgentApprovalRequest(BaseModel): + """Resolve a waiting frontend Agent approval.""" + + model_config = ConfigDict(extra="forbid") + + action: str = Field(..., pattern="^(accept|edit|abort)$") + patch: Dict[str, Any] = Field(default_factory=dict) + + +class WikiQueryRequest(BaseModel): + """Request body for POST /v1/wiki/query.""" + + model_config = ConfigDict(extra="forbid") + + query: str = Field(..., min_length=1) + task_id: Optional[str] = Field(default=None, description="Optional chat session id for persistence.") + topk: int = Field(default=6, ge=1, le=12) + style: str = Field(default="teach") + + +class WikiApproveRequest(BaseModel): + """Request body for POST /v1/wiki/approve.""" + + model_config = ConfigDict(extra="forbid") + + selector: str = "" + + +class CaseSearchRequest(BaseModel): + """Request body for POST /v1/cases/search.""" + + model_config = ConfigDict(extra="forbid") + + query: str = Field(..., min_length=1) + task_id: Optional[str] = Field(default=None, description="Optional chat session id for persistence.") + + +class HotRunRequest(BaseModel): + """Request body for POST /v1/hot/run.""" + + model_config = ConfigDict(extra="forbid") + + config_path: str = "" + + +class ArtifactPathResponse(BaseModel): + """Generic path-producing workflow response.""" + + model_config = ConfigDict(extra="allow") + + status: str = "succeeded" + path: str = "" + + +class ModelInfo(BaseModel): + """Model configuration entry safe for frontend display.""" + + model_config = ConfigDict(extra="allow") + + name: str + description: str = "" + provider: str = "" + model: str = "" + api_key_env: str = "" + + +class ModelListResponse(BaseModel): + """GET /v1/models payload.""" + + model_config = ConfigDict(extra="forbid") + + models: List[ModelInfo] = Field(default_factory=list) + + +class ToolInfo(BaseModel): + """Agent tool metadata safe for frontend display.""" + + model_config = ConfigDict(extra="allow") + + name: str + description: str = "" + + +class ToolListResponse(BaseModel): + """GET /v1/tools payload.""" + + model_config = ConfigDict(extra="forbid") + + tools: List[ToolInfo] = Field(default_factory=list) + + +class ComposerCommand(BaseModel): + """Slash command shortcut exposed in the chat composer.""" + + model_config = ConfigDict(extra="forbid") + + id: str + label: str + command: str = Field(..., description="Slash command prefix, e.g. /event") + description: str = "" + action: str = Field(default="prefill", description="prefill | run") + default_query: str = Field( + default="", + description="Full command executed when action=run, e.g. '/monitor list'", + ) + + +class ComposerCommandListResponse(BaseModel): + """GET /v1/commands payload.""" + + model_config = ConfigDict(extra="forbid") + + commands: List[ComposerCommand] = Field(default_factory=list) + + +class MonitorTopicCreateRequest(BaseModel): + """Create a monitoring topic.""" + + model_config = ConfigDict(extra="forbid") + + name: str = Field(..., min_length=1) + domain: str = Field(default="综合舆情") + keywords: List[str] = Field(default_factory=list) + description: str = "" + run_initial_cycle: bool = True + + +class MonitorReportRequest(BaseModel): + """Generate a periodic monitoring report.""" + + model_config = ConfigDict(extra="forbid") + + period: str = Field(default="daily") + + +class MonitorTopicListResponse(BaseModel): + """GET /v1/monitor/topics payload.""" + + model_config = ConfigDict(extra="forbid") + + topics: List[Dict[str, Any]] = Field(default_factory=list) + + class HealthResponse(BaseModel): """GET /health payload.""" diff --git a/api/server.py b/api/server.py index 05749d0c..0a4025ea 100644 --- a/api/server.py +++ b/api/server.py @@ -2,16 +2,54 @@ from __future__ import annotations +import json import os +import threading from pathlib import Path +from typing import Any, Dict, Iterable from fastapi import APIRouter, Depends, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, StreamingResponse from api.event_runner import run_analyze_event -from api.schema import AnalyzeEventRequest, HealthResponse, TaskEnvelope, TaskListResponse, TaskStatus +from api.agent_run_store import AgentRunRecord, get_agent_run_store +from api.report_utils import build_task_envelope_from_session, resolve_report_path_for_task +from api.schema import ( + AgentApprovalRequest, + AgentEventType, + AgentRunCreateRequest, + AgentRunEnvelope, + AgentRunStatus, + AnalyzeEventRequest, + ArtifactPathResponse, + CaseSearchRequest, + ChatMessageRequest, + HealthResponse, + HotRunRequest, + ModelInfo, + ModelListResponse, + MonitorReportRequest, + MonitorTopicCreateRequest, + MonitorTopicListResponse, + SessionCreateRequest, + SessionEnvelope, + SessionListResponse, + SessionUpdateRequest, + TaskEnvelope, + TaskListResponse, + TaskStatus, + ComposerCommand, + ComposerCommandListResponse, + ToolInfo, + ToolListResponse, + WikiApproveRequest, + WikiQueryRequest, +) from api.task_store import TaskStore, get_task_store +from utils.message_utils import messages_from_session_data +from utils.path import ensure_task_dirs, get_project_root +from utils.session_manager import get_session_manager def _cors_settings() -> tuple[list[str], bool]: @@ -38,6 +76,654 @@ def _cors_settings() -> tuple[list[str], bool]: v1_router = APIRouter(prefix="/v1", tags=["workflows"]) +def _session_envelope(data: Dict[str, Any]) -> SessionEnvelope: + """Normalize loose session JSON into the public session shape.""" + return SessionEnvelope( + task_id=str(data.get("task_id") or ""), + created_at=str(data.get("created_at") or ""), + updated_at=str(data.get("updated_at") or ""), + description=str(data.get("description") or ""), + initial_query=str(data.get("initial_query") or ""), + messages=data.get("messages") if isinstance(data.get("messages"), list) else [], + token_usage=data.get("token_usage") if isinstance(data.get("token_usage"), dict) else {}, + ) + + +def _sse(event_name: str, payload: Dict[str, Any]) -> str: + """Format one server-sent event frame.""" + return f"event: {event_name}\ndata: {json.dumps(payload, ensure_ascii=False, default=str)}\n\n" + + +def _message_content(message: Any) -> str: + """Extract printable content from LangChain messages or plain values.""" + if hasattr(message, "content"): + return str(getattr(message, "content") or "") + if isinstance(message, dict): + return str(message.get("content") or "") + return str(message or "") + + +def _tool_calls(message: Any) -> list[dict[str, Any]]: + raw = getattr(message, "tool_calls", None) or [] + out: list[dict[str, Any]] = [] + for item in raw: + if isinstance(item, dict): + out.append(item) + else: + out.append( + { + "name": getattr(item, "name", ""), + "args": getattr(item, "args", {}), + "id": getattr(item, "id", ""), + } + ) + return out + + +def _looks_like_tool_result_json(content: str) -> bool: + try: + parsed = json.loads(content) + except (json.JSONDecodeError, TypeError): + return False + if not isinstance(parsed, dict): + return False + tool_result_fields = { + "eventIntroduction", + "searchWords", + "timeRange", + "search_matrix", + "result_file_path", + "save_path", + "html_file_path", + "file_url", + } + return any(field in parsed for field in tool_result_fields) + + +def _execution_plan_steps(route_decision: str, route_data: Dict[str, Any]) -> list[str]: + intent_result = route_data.get("intent_result") + data_result = route_data.get("data_result") + has_data = bool(getattr(data_result, "has_data", False)) if data_result is not None else False + + if route_decision in ("event_analysis_workflow", "event_analysis_with_existing_data"): + if has_data and route_decision == "event_analysis_with_existing_data": + steps = [ + "extract_search_terms(确认事件与关键词)", + "复用已有数据(跳过 data_collect)", + "analysis_timeline(时间线)", + "analysis_sentiment(情感)", + "keyword/region/author/volume 统计", + "report_html(生成报告)", + ] + else: + steps = [ + "extract_search_terms(提取事件与检索参数)", + "data_num(关键词数量分配)", + "data_collect(采集数据)", + "analysis_timeline(时间线)", + "analysis_sentiment(情感)", + "keyword/region/author/volume 统计", + "report_html(生成报告)", + ] + elif route_decision == "event_brief_workflow": + steps = [ + "extract_search_terms(提取事件简介/关键词/时间范围)", + "输出轻量事件概述(不采集、不生成长报告)", + ] + elif route_decision == "hottopics_workflow": + steps = ["run_hot_command(热点聚合与态势感知)", "输出热点分析结果"] + else: + steps = ["reactagent(按 ReAct 决策按需调用工具)", "返回问答结果"] + + reason = str(getattr(intent_result, "reasoning", "") or "").strip() + if reason: + steps.insert(0, f"路由依据:{reason}") + return steps + + +def _sync_session_to_task_store( + task_id: str, + *, + failed: bool = False, + error_message: str = "", +) -> None: + """Mirror chat session workflow output into the in-memory task store.""" + get_task_store().put( + build_task_envelope_from_session( + task_id, + failed=failed, + error_message=error_message, + ) + ) + + +def _persist_agent_event(task_id: str, event: Dict[str, Any]) -> None: + get_session_manager().add_message( + task_id, + "system", + json.dumps({"event": "agent_run_event", **event}, ensure_ascii=False), + ) + + +def _agent_run_event_frame(record: AgentRunRecord, event: Dict[str, Any]) -> str: + payload = dict(event) + payload.setdefault("run_id", record.run_id) + payload.setdefault("task_id", record.task_id) + payload.setdefault("turn_id", record.turn_id) + return _sse(str(payload.get("event_type") or "agent_event"), payload) + + +def _append_agent_event( + record: AgentRunRecord, + event_queue: "queue.Queue[Dict[str, Any]]", + event_type: AgentEventType | str, + *, + status: str = "", + title: str = "", + detail: str = "", + payload: Dict[str, Any] | None = None, +) -> Dict[str, Any]: + event = record.add_event( + event_type, + status=status, + title=title, + detail=detail, + payload=payload or {}, + ) + data = event.model_dump(mode="json") + _persist_agent_event(record.task_id, data) + event_queue.put(data) + return data + + +def _agent_run_stream(record: AgentRunRecord) -> Iterable[str]: + """Run or replay a frontend Agent run as canonical SSE events.""" + import queue + + from agent.reactagent import stream as agent_stream + from cli.router import route_query + + event_queue: "queue.Queue[Dict[str, Any]]" = queue.Queue() + manager = get_session_manager() + + if record.started: + for event in record.events: + yield _agent_run_event_frame(record, event.model_dump(mode="json")) + return + + record.started = True + + def worker() -> None: + streamed_content = "" + try: + record.status = AgentRunStatus.RUNNING + _append_agent_event( + record, + event_queue, + AgentEventType.AGENT_STEP_STARTED, + status="running", + title="路由与执行计划", + detail="正在判断任务类型并准备执行。", + ) + + route_decision = "reactagent" + route_data: Dict[str, Any] = {} + task_mode = "qa" + workflow_options: Dict[str, Any] = dict(record.options.get("workflow_options") or {}) + if record.options.get("auto_route", True): + route_decision, route_data = route_query(record.query, record.task_id) + data_result = route_data.get("data_result") + route_policy = route_data.get("route_policy", {}) or {} + if route_decision in ("event_analysis_workflow", "event_analysis_with_existing_data"): + task_mode = "full_report" + workflow_options.setdefault("report_length", route_policy.get("report_length") or "中篇") + if ( + record.options.get("prefer_existing_data", True) + and data_result + and getattr(data_result, "has_data", False) + and getattr(data_result, "data_paths", None) + ): + workflow_options["existing_data_path"] = data_result.data_paths[0] + workflow_options["skip_data_collect"] = True + elif route_decision == "event_brief_workflow": + task_mode = "brief" + + _append_agent_event( + record, + event_queue, + AgentEventType.AGENT_STEP_COMPLETED, + status="completed", + title="路由完成", + detail=f"{route_decision} · {task_mode}", + payload={"route": route_decision, "task_mode": task_mode}, + ) + + for index, step in enumerate(_execution_plan_steps(route_decision, route_data), start=1): + _append_agent_event( + record, + event_queue, + AgentEventType.AGENT_STEP_STARTED, + status="running", + title=f"执行计划 {index}", + detail=step, + payload={"step": f"plan_{index}"}, + ) + + def approval_hook(progress_event: Dict[str, Any]) -> Dict[str, Any] | None: + step = str(progress_event.get("step") or "") + title = str(progress_event.get("title") or "") + if step != "collect_plan" and "采集方案" not in title: + return None + payload = progress_event.get("payload") + if not isinstance(payload, dict): + payload = {} + approval = _append_agent_event( + record, + event_queue, + AgentEventType.APPROVAL_REQUESTED, + status="pending", + title=title or "建议搜索采集方案(等待确认)", + detail=str(progress_event.get("detail") or ""), + payload=payload, + ) + record.status = AgentRunStatus.WAITING_APPROVAL + record.pending_approval_id = str(approval.get("event_id") or "") + with record.approval_condition: + while record.approval_decision is None: + record.approval_condition.wait(timeout=1.0) + decision = dict(record.approval_decision) + record.approval_decision = None + action = str(decision.get("action") or "accept") + if action == "abort": + record.status = AgentRunStatus.ABORTED + approval_event_id = record.pending_approval_id + record.pending_approval_id = "" + _append_agent_event( + record, + event_queue, + AgentEventType.APPROVAL_RESOLVED, + status="aborted", + title="采集方案已终止", + detail="用户终止了本次工作流。", + payload={"approval_event_id": approval_event_id, "action": action}, + ) + raise RuntimeError("Agent run aborted by user") + if action == "edit": + patch = decision.get("patch") if isinstance(decision.get("patch"), dict) else {} + payload.update(patch) + record.status = AgentRunStatus.RUNNING + approval_event_id = record.pending_approval_id + record.pending_approval_id = "" + _append_agent_event( + record, + event_queue, + AgentEventType.APPROVAL_RESOLVED, + status="accepted" if action == "accept" else "edited", + title="采集方案已确认", + detail="用户已确认采集方案,工作流继续执行。", + payload={ + "approval_event_id": approval_event_id, + "action": action, + "patch": decision.get("patch") if isinstance(decision.get("patch"), dict) else {}, + }, + ) + return {"action": action, "patch": decision.get("patch") if isinstance(decision.get("patch"), dict) else {}} + + workflow_options["_web_progress_hook"] = approval_hook + + session_data = manager.load_session(record.task_id) or {} + previous_messages = messages_from_session_data(session_data) + assistant_persisted = False + for item in agent_stream( + record.query, + task_id=record.task_id, + previous_messages=previous_messages, + task_mode=task_mode, + workflow_options=workflow_options, + ): + if not isinstance(item, dict): + _append_agent_event( + record, + event_queue, + AgentEventType.AGENT_STEP_UPDATED, + status="running", + title="状态更新", + detail=str(item), + ) + continue + item_type = str(item.get("type") or "") + if item_type == "token": + accumulated = str(item.get("accumulated") or "").strip() + if accumulated: + streamed_content = accumulated + elif item.get("content"): + streamed_content += str(item.get("content") or "") + _append_agent_event( + record, + event_queue, + AgentEventType.AGENT_MESSAGE_DELTA, + status="running", + title="回复生成", + detail=streamed_content, + payload={"content": item.get("content", ""), "accumulated": streamed_content}, + ) + elif item_type == "message": + content = _message_content(item.get("message")).strip() + if content: + streamed_content = content + manager.add_message(record.task_id, "assistant", content) + assistant_persisted = True + _append_agent_event( + record, + event_queue, + AgentEventType.AGENT_MESSAGE_DELTA, + status="running", + title="回复生成", + detail=content, + ) + elif item_type == "tool_call": + _append_agent_event( + record, + event_queue, + AgentEventType.TOOL_CALL_STARTED, + status="running", + title=str(item.get("tool_name") or "工具调用"), + detail=json.dumps(item.get("args", {}), ensure_ascii=False, indent=2), + payload={"tool_name": item.get("tool_name", ""), "args": item.get("args", {}), "run_id": item.get("run_id", "")}, + ) + elif item_type == "tool_result": + result = str(item.get("result") or "") + manager.add_message( + record.task_id, + "tool", + result, + tool_name=str(item.get("tool_name") or "unknown"), + tool_call_id=str(item.get("run_id") or ""), + ) + event_type = AgentEventType.TOOL_CALL_COMPLETED + if "report" in str(item.get("tool_name") or "").lower() or "report_" in result: + event_type = AgentEventType.ARTIFACT_CREATED + _append_agent_event( + record, + event_queue, + event_type, + status="completed", + title=str(item.get("tool_name") or "工具结果"), + detail=result, + payload={"tool_name": item.get("tool_name", ""), "result": result, "run_id": item.get("run_id", "")}, + ) + elif item_type == "workflow_step": + _append_agent_event( + record, + event_queue, + AgentEventType.AGENT_STEP_UPDATED, + status="running", + title=str(item.get("title") or "工作流"), + detail=str(item.get("detail") or ""), + payload={"step": str(item.get("step") or ""), "payload": item.get("payload", {})}, + ) + elif item_type == "compression": + _append_agent_event( + record, + event_queue, + AgentEventType.AGENT_STEP_COMPLETED, + status="completed", + title="上下文压缩", + detail=str(item.get("summary") or item.get("message") or "上下文已压缩"), + ) + + if streamed_content.strip() and not assistant_persisted: + manager.add_message(record.task_id, "assistant", streamed_content.strip()) + record.status = AgentRunStatus.SUCCEEDED + _sync_session_to_task_store(record.task_id) + _append_agent_event( + record, + event_queue, + AgentEventType.RUN_COMPLETED, + status="succeeded", + title="工作流完成", + detail="本次 Agent run 已完成。", + ) + except Exception as exc: # noqa: BLE001 + if record.status == AgentRunStatus.ABORTED: + _sync_session_to_task_store(record.task_id, failed=True, error_message="Agent run aborted by user") + _append_agent_event( + record, + event_queue, + AgentEventType.RUN_FAILED, + status="aborted", + title="工作流已终止", + detail="用户终止了本次工作流。", + ) + else: + record.status = AgentRunStatus.FAILED + _sync_session_to_task_store(record.task_id, failed=True, error_message=str(exc)) + _append_agent_event( + record, + event_queue, + AgentEventType.RUN_FAILED, + status="failed", + title="工作流失败", + detail=str(exc), + ) + finally: + record.finished = True + event_queue.put({"event_type": "__end__"}) + + threading.Thread(target=worker, daemon=True).start() + while True: + event = event_queue.get() + if event.get("event_type") == "__end__": + break + yield _agent_run_event_frame(record, event) + + +def _chat_stream( + *, + task_id: str, + body: ChatMessageRequest, +) -> Iterable[str]: + """Bridge agent stream events into SSE while keeping SessionManager updated.""" + from agent.reactagent import stream as agent_stream + from cli.router import route_query + + manager = get_session_manager() + session_data = manager.load_session(task_id) + if not session_data: + yield _sse("error", {"error": "SESSION_NOT_FOUND", "message": "Session not found"}) + return + + query = body.query.strip() + route_decision = "reactagent" + route_data: Dict[str, Any] = {} + task_mode = "qa" + workflow_options: Dict[str, Any] = dict(body.workflow_options or {}) + previous_messages = messages_from_session_data(session_data) + + if body.auto_route: + try: + route_decision, route_data = route_query(query, task_id) + data_result = route_data.get("data_result") + route_policy = route_data.get("route_policy", {}) or {} + if route_decision in ("event_analysis_workflow", "event_analysis_with_existing_data"): + task_mode = "full_report" + workflow_options.setdefault("report_length", route_policy.get("report_length") or "中篇") + if ( + body.prefer_existing_data + and data_result + and getattr(data_result, "has_data", False) + and getattr(data_result, "data_paths", None) + ): + workflow_options["existing_data_path"] = data_result.data_paths[0] + workflow_options["skip_data_collect"] = True + elif route_decision == "event_brief_workflow": + task_mode = "brief" + elif route_decision == "hottopics_workflow": + yield _sse("route", {"route": route_decision, "task_mode": "hot"}) + yield from _run_hot_as_sse() + manager.add_message(task_id, "user", query) + manager.add_message(task_id, "assistant", "已执行热点态势感知流程。") + return + except Exception as exc: # noqa: BLE001 + yield _sse("route_error", {"message": str(exc)}) + + yield _sse("route", {"route": route_decision, "task_mode": task_mode}) + for index, step in enumerate(_execution_plan_steps(route_decision, route_data), start=1): + yield _sse( + "workflow_step", + { + "step": f"plan_{index}", + "title": f"执行计划 {index}", + "detail": step, + }, + ) + manager.add_message(task_id, "user", query) + + streamed_content = "" + + def _persist_streamed_reply() -> None: + """Persist token-streamed assistant text when no final message was saved.""" + nonlocal streamed_content + text = streamed_content.strip() + if not text: + return + session = manager.load_session(task_id) or {} + messages = session.get("messages") if isinstance(session.get("messages"), list) else [] + last = messages[-1] if messages else None + if ( + isinstance(last, dict) + and last.get("role") == "assistant" + and str(last.get("content") or "").strip() == text + ): + streamed_content = "" + return + manager.add_message(task_id, "assistant", text) + streamed_content = "" + + try: + for item in agent_stream( + query, + task_id=task_id, + previous_messages=previous_messages, + task_mode=task_mode, + workflow_options=workflow_options, + ): + if not isinstance(item, dict): + yield _sse("state_update", {"value": str(item)}) + continue + + item_type = str(item.get("type") or "state_update") + if item_type == "message": + content = _message_content(item.get("message")).strip() + calls = _tool_calls(item.get("message")) + is_tool_result_json = bool(content and not calls and _looks_like_tool_result_json(content)) + if content or calls: + if calls or not is_tool_result_json: + manager.add_message(task_id, "assistant", content, tool_calls=calls or None) + streamed_content = "" + yield _sse( + "message", + { + "message_id": item.get("message_id", ""), + "content": "" if is_tool_result_json else content, + "tool_calls": calls, + }, + ) + elif item_type == "tool_call": + yield _sse( + "tool_call", + { + "tool_name": item.get("tool_name", ""), + "args": item.get("args", {}), + "run_id": item.get("run_id", ""), + }, + ) + elif item_type == "tool_result": + result = str(item.get("result") or "") + manager.add_message( + task_id, + "tool", + result, + tool_name=str(item.get("tool_name") or "unknown"), + tool_call_id=str(item.get("run_id") or ""), + ) + yield _sse( + "tool_result", + { + "tool_name": item.get("tool_name", ""), + "result": result, + "run_id": item.get("run_id", ""), + }, + ) + elif item_type == "token": + accumulated = str(item.get("accumulated") or "").strip() + if accumulated: + streamed_content = accumulated + elif item.get("content"): + streamed_content += str(item.get("content") or "") + yield _sse( + "token", + { + "content": item.get("content", ""), + "message_id": item.get("message_id", ""), + "accumulated": streamed_content, + }, + ) + elif item_type == "compression": + compressed = item.get("compressed_messages") + if isinstance(compressed, list): + manager.replace_messages(task_id, compressed, reset_token_usage=True) + yield _sse("compression", item) + elif item_type == "workflow_step": + workflow_event = { + "event": "workflow_step", + "step": str(item.get("step") or ""), + "title": str(item.get("title") or ""), + "detail": str(item.get("detail") or ""), + "payload": item.get("payload", {}), + } + manager.add_message( + task_id, + "system", + json.dumps(workflow_event, ensure_ascii=False), + ) + yield _sse( + "workflow_step", + { + "step": workflow_event["step"], + "title": workflow_event["title"], + "detail": workflow_event["detail"], + "payload": workflow_event["payload"], + }, + ) + else: + yield _sse(item_type, {"payload": item}) + _persist_streamed_reply() + _sync_session_to_task_store(task_id) + yield _sse("done", {"task_id": task_id}) + except Exception as exc: # noqa: BLE001 + _persist_streamed_reply() + _sync_session_to_task_store(task_id, failed=True, error_message=str(exc)) + yield _sse("error", {"error": "CHAT_STREAM_ERROR", "message": str(exc)}) + + +def _run_hot_as_sse() -> Iterable[str]: + yield _sse("tool_call", {"tool_name": "hottopics", "args": {}, "run_id": "hot"}) + try: + from utils.hot_topics_env import ensure_hot_topics_cwd, prepare_hot_topics_environment + + prepare_hot_topics_environment() + ensure_hot_topics_cwd() + from tools.hottopics import run as run_hot_topics + + report_path = run_hot_topics(config_path=None) + yield _sse("tool_result", {"tool_name": "hottopics", "result": report_path, "run_id": "hot"}) + yield _sse("message", {"content": f"热点态势报告已生成:{report_path or '未返回路径'}"}) + yield _sse("done", {"path": report_path}) + except Exception as exc: # noqa: BLE001 + yield _sse("error", {"error": "HOT_WORKFLOW_ERROR", "message": str(exc)}) + + @v1_router.post("/analyze-event", response_model=TaskEnvelope) def analyze_event( body: AnalyzeEventRequest, @@ -49,6 +735,372 @@ def analyze_event( return envelope +@v1_router.post("/chat/sessions", response_model=SessionEnvelope) +def create_chat_session(body: SessionCreateRequest) -> SessionEnvelope: + """Create a frontend chat session, equivalent to CLI /new.""" + manager = get_session_manager() + task_id = manager.create_session(body.initial_query.strip() or "Frontend session") + ensure_task_dirs(task_id) + data = manager.load_session(task_id) or {"task_id": task_id} + return _session_envelope(data) + + +@v1_router.get("/chat/sessions", response_model=SessionListResponse) +def list_chat_sessions(limit: int = 20) -> SessionListResponse: + """List persisted chat sessions, equivalent to CLI /memory.""" + manager = get_session_manager() + sessions = [_session_envelope(item) for item in manager.list_sessions(limit=max(1, min(limit, 100)))] + return SessionListResponse(sessions=sessions) + + +@v1_router.get("/chat/sessions/{task_id}", response_model=SessionEnvelope) +def get_chat_session(task_id: str) -> SessionEnvelope: + """Return one persisted chat session.""" + data = get_session_manager().load_session(task_id) + if data is None: + raise HTTPException(status_code=404, detail="Session not found") + return _session_envelope(data) + + +@v1_router.patch("/chat/sessions/{task_id}", response_model=SessionEnvelope) +def update_chat_session(task_id: str, body: SessionUpdateRequest) -> SessionEnvelope: + """Update persisted chat session metadata.""" + data = get_session_manager().update_session( + task_id, + description=body.description.strip() if body.description else None, + ) + if data is None: + raise HTTPException(status_code=404, detail="Session not found") + return _session_envelope(data) + + +@v1_router.delete("/chat/sessions/{task_id}", response_model=SessionListResponse) +def delete_chat_session(task_id: str, limit: int = 20) -> SessionListResponse: + """Delete a persisted chat session and return the remaining recent sessions.""" + manager = get_session_manager() + if not manager.delete_session(task_id): + raise HTTPException(status_code=404, detail="Session not found") + get_task_store().delete(task_id) + sessions = [_session_envelope(item) for item in manager.list_sessions(limit=max(1, min(limit, 100)))] + return SessionListResponse(sessions=sessions) + + +@v1_router.post("/chat/sessions/{task_id}/runs", response_model=AgentRunEnvelope) +def create_agent_run(task_id: str, body: AgentRunCreateRequest) -> AgentRunEnvelope: + """Create a canonical web Agent run; stream it from /events.""" + manager = get_session_manager() + if manager.load_session(task_id) is None: + raise HTTPException(status_code=404, detail="Session not found") + query = body.query.strip() + if not query: + raise HTTPException(status_code=422, detail="query is required") + manager.add_message(task_id, "user", query) + record = get_agent_run_store().create( + task_id=task_id, + query=query, + options={ + "auto_route": body.auto_route, + "prefer_existing_data": body.prefer_existing_data, + "workflow_options": body.workflow_options, + }, + ) + return record.envelope() + + +@v1_router.get("/chat/sessions/{task_id}/runs/{run_id}", response_model=AgentRunEnvelope) +def get_agent_run(task_id: str, run_id: str) -> AgentRunEnvelope: + """Return persisted web Agent run state.""" + record = get_agent_run_store().get(run_id) + if record is None or record.task_id != task_id: + raise HTTPException(status_code=404, detail="Agent run not found") + return record.envelope() + + +@v1_router.get("/chat/sessions/{task_id}/runs/{run_id}/events") +def stream_agent_run_events(task_id: str, run_id: str) -> StreamingResponse: + """Stream canonical web Agent events for one run.""" + record = get_agent_run_store().get(run_id) + if record is None or record.task_id != task_id: + raise HTTPException(status_code=404, detail="Agent run not found") + return StreamingResponse( + _agent_run_stream(record), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +@v1_router.post("/chat/sessions/{task_id}/runs/{run_id}/approval", response_model=AgentRunEnvelope) +def resolve_agent_run_approval(task_id: str, run_id: str, body: AgentApprovalRequest) -> AgentRunEnvelope: + """Resolve a pending Agent approval request.""" + record = get_agent_run_store().get(run_id) + if record is None or record.task_id != task_id: + raise HTTPException(status_code=404, detail="Agent run not found") + if record.status != AgentRunStatus.WAITING_APPROVAL or not record.pending_approval_id: + raise HTTPException(status_code=409, detail="Agent run is not waiting for approval") + with record.approval_condition: + record.approval_decision = {"action": body.action, "patch": body.patch} + record.approval_condition.notify_all() + return record.envelope() + + +@v1_router.post("/chat/sessions/{task_id}/messages:stream") +def stream_chat_message(task_id: str, body: ChatMessageRequest) -> StreamingResponse: + """Stream a chat response as SSE, including token/tool/message events.""" + if get_session_manager().load_session(task_id) is None: + raise HTTPException(status_code=404, detail="Session not found") + return StreamingResponse( + _chat_stream(task_id=task_id, body=body), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +@v1_router.post("/wiki/query") +def query_wiki(body: WikiQueryRequest) -> Dict[str, Any]: + """Run local Wiki/RAG query, equivalent to CLI /wiki.""" + from workflow.wiki_cli import answer_wiki_query + + result = answer_wiki_query( + body.query, + topk=body.topk, + style=body.style, + project_root=get_project_root(), + ) + if body.task_id: + manager = get_session_manager() + if manager.load_session(body.task_id): + manager.add_message(body.task_id, "user", body.query) + manager.add_message(body.task_id, "assistant", str(result.get("answer") or "")) + return result + + +@v1_router.post("/wiki/approve") +def approve_wiki_candidate(body: WikiApproveRequest) -> Dict[str, Any]: + """Approve latest or selected Wiki candidate, equivalent to CLI /wiki-approve.""" + from cli.wiki_ui import _approve_candidate_to_output, _pick_candidate + + root = get_project_root() + candidate = _pick_candidate(root, selector=body.selector or None) + if candidate is None: + raise HTTPException(status_code=404, detail="No wiki candidate found") + return _approve_candidate_to_output(root, candidate) + + +@v1_router.post("/cases/search") +def search_cases(body: CaseSearchRequest) -> Dict[str, Any]: + """Search local case library, equivalent to CLI /case.""" + from workflow.wiki_cli import answer_case_query + + result = answer_case_query(body.query, project_root=get_project_root()) + if body.task_id: + manager = get_session_manager() + if manager.load_session(body.task_id): + manager.add_message(body.task_id, "user", body.query) + manager.add_message(body.task_id, "assistant", str(result.get("answer") or "")) + return result + + +@v1_router.post("/hot/run", response_model=ArtifactPathResponse) +def run_hot(body: HotRunRequest) -> ArtifactPathResponse: + """Run hot topics workflow, equivalent to CLI /hot.""" + from utils.hot_topics_env import ensure_hot_topics_cwd, prepare_hot_topics_environment + + prepare_hot_topics_environment() + ensure_hot_topics_cwd() + from tools.hottopics import run as run_hot_topics + + config_path = body.config_path.strip() or None + report_path = run_hot_topics(config_path=config_path) + return ArtifactPathResponse(path=report_path or "") + + +@v1_router.get("/models", response_model=ModelListResponse) +def list_models() -> ModelListResponse: + """Return model configuration as JSON, equivalent to CLI /models.""" + import yaml + + config_path = get_project_root() / "config" / "model.yaml" + if not config_path.is_file(): + raise HTTPException(status_code=404, detail="model.yaml not found") + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + descriptions = { + "main": "主流程模型:作为 ReAct Agent 的底座", + "tools": "工具模型:用于各种工具调用", + "extract": "搜索词提取模型", + "analysis": "分析模型:时间线、情感等", + "report": "HTML 报告生成模型", + } + models: list[ModelInfo] = [] + for name in ["main", "tools", "extract", "analysis", "report"]: + value = config.get(name) + if not isinstance(value, dict): + continue + models.append( + ModelInfo( + name=name, + description=descriptions.get(name, ""), + provider=str(value.get("provider") or ""), + model=str(value.get("model") or ""), + api_key_env=str(value.get("api_key_env") or ""), + ) + ) + return ModelListResponse(models=models) + + +def _extract_tool_description(text: str) -> str: + if not text: + return "" + marker = "描述:" + if marker in text: + text = text.split(marker, 1)[1] + for section in ("使用时机:", "输入:", "输出:", "注意:"): + if section in text: + text = text.split(section, 1)[0] + return " ".join(text.split())[:600] + + +COMPOSER_COMMANDS: list[ComposerCommand] = [ + ComposerCommand( + id="event", + label="事件分析", + command="/event", + description="调用 POST /v1/analyze-event,生成事件脉络与 HTML 报告", + action="prefill", + ), + ComposerCommand( + id="wiki", + label="知识库", + command="/wiki", + description="调用 POST /v1/wiki/query,查询本地 Wiki", + action="prefill", + ), + ComposerCommand( + id="case", + label="案例检索", + command="/case", + description="调用 POST /v1/cases/search,检索历史案例", + action="prefill", + ), + ComposerCommand( + id="hot", + label="热点", + command="/hot", + description="调用 POST /v1/hot/run", + action="run", + default_query="/hot", + ), + ComposerCommand( + id="monitor_list", + label="专题", + command="/monitor", + description="GET /v1/monitor/topics", + action="run", + default_query="/monitor list", + ), + ComposerCommand( + id="monitor_demo", + label="专题演示", + command="/monitor", + description="POST /v1/monitor/demo", + action="run", + default_query="/monitor demo", + ), + ComposerCommand( + id="memory", + label="最近话题", + command="/memory", + description="GET /v1/chat/sessions", + action="run", + default_query="/memory", + ), +] + + +@v1_router.get("/commands", response_model=ComposerCommandListResponse) +def list_composer_commands() -> ComposerCommandListResponse: + """Return slash-command shortcuts for the chat composer toolbar.""" + return ComposerCommandListResponse(commands=COMPOSER_COMMANDS) + + +@v1_router.get("/tools", response_model=ToolListResponse) +def list_tools() -> ToolListResponse: + """Return agent tool metadata as JSON, equivalent to CLI /tools.""" + from agent.reactagent import AGENT_TOOLS + + tools: list[ToolInfo] = [] + for tool in AGENT_TOOLS: + name = str(getattr(tool, "name", "") or tool) + description = str(getattr(tool, "description", "") or getattr(tool, "__doc__", "") or "") + tools.append(ToolInfo(name=name, description=_extract_tool_description(description))) + return ToolListResponse(tools=tools) + + +@v1_router.get("/monitor/topics", response_model=MonitorTopicListResponse) +def list_monitor_topics() -> MonitorTopicListResponse: + """List monitoring topics, equivalent to /monitor list.""" + from workflow.topic_monitoring_pipeline import TopicMonitoringPipeline + + pipeline = TopicMonitoringPipeline() + return MonitorTopicListResponse(topics=pipeline.db.list_monitor_topics()) + + +@v1_router.post("/monitor/demo") +def run_monitor_demo() -> Dict[str, Any]: + """Run built-in high-speed-rail monitor demo, equivalent to /monitor demo.""" + from workflow.topic_monitoring_pipeline import run_high_speed_rail_demo + + return run_high_speed_rail_demo() + + +@v1_router.post("/monitor/topics") +def create_monitor_topic(body: MonitorTopicCreateRequest) -> Dict[str, Any]: + """Create a monitoring topic, equivalent to /monitor create.""" + from workflow.topic_monitoring_pipeline import TopicMonitoringPipeline + + pipeline = TopicMonitoringPipeline() + topic = pipeline.create_topic( + name=body.name, + domain=body.domain, + keywords=body.keywords, + description=body.description or "由前端 API 创建", + owner="frontend", + ) + result: Dict[str, Any] = {"topic": topic} + if body.run_initial_cycle: + result["cycle"] = pipeline.run_monitoring_cycle([str(topic.get("id") or "")]) + return result + + +@v1_router.get("/monitor/topics/{topic_id}/status") +def get_monitor_topic_status(topic_id: str) -> Dict[str, Any]: + """Return monitoring topic status, equivalent to /monitor status.""" + from workflow.topic_monitoring_pipeline import TopicMonitoringPipeline + + result = TopicMonitoringPipeline().get_topic_status(topic_id) + if result.get("error"): + raise HTTPException(status_code=404, detail=str(result["error"])) + return result + + +@v1_router.post("/monitor/topics/{topic_id}/cycle") +def run_monitor_topic_cycle(topic_id: str) -> Dict[str, Any]: + """Run one monitoring cycle for a topic.""" + from workflow.topic_monitoring_pipeline import TopicMonitoringPipeline + + return TopicMonitoringPipeline().run_monitoring_cycle([topic_id]) + + +@v1_router.post("/monitor/topics/{topic_id}/report") +def create_monitor_topic_report(topic_id: str, body: MonitorReportRequest) -> Dict[str, Any]: + """Generate monitor daily/weekly report, equivalent to /monitor report.""" + from workflow.topic_monitoring_pipeline import TopicMonitoringPipeline + + try: + return TopicMonitoringPipeline().generate_periodic_report(topic_id, period=body.period) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @v1_router.get("/tasks", response_model=TaskListResponse) def list_tasks(store: TaskStore = Depends(get_task_store)) -> TaskListResponse: """List tasks stored in the current API process memory (for Streamlit GUI).""" @@ -68,14 +1120,12 @@ def get_task(task_id: str, store: TaskStore = Depends(get_task_store)) -> TaskEn def get_task_report(task_id: str, store: TaskStore = Depends(get_task_store)) -> FileResponse: """Return HTML report file when available (mode B in docs/api_design.md).""" env = store.get(task_id) - if env is None: - raise HTTPException(status_code=404, detail="Task not found") - if env.status != TaskStatus.SUCCEEDED: + if env is not None and env.status != TaskStatus.SUCCEEDED: raise HTTPException( status_code=409, detail="Task did not succeed; report unavailable", ) - raw_path = (env.artifacts.report_path or "").strip() + raw_path = resolve_report_path_for_task(task_id) if not raw_path: raise HTTPException(status_code=404, detail="Report path not recorded") path = Path(raw_path).expanduser() diff --git a/api/task_store.py b/api/task_store.py index 8220d945..cad3ee5b 100644 --- a/api/task_store.py +++ b/api/task_store.py @@ -23,6 +23,10 @@ def get(self, task_id: str) -> Optional[TaskEnvelope]: with self._lock: return self._tasks.get(task_id) + def delete(self, task_id: str) -> None: + with self._lock: + self._tasks.pop(task_id, None) + def list_all(self) -> List[TaskEnvelope]: """Return all stored envelopes (newest last; order not guaranteed).""" with self._lock: diff --git a/cli/event_analysis_workflow.py b/cli/event_analysis_workflow.py index dd3e5231..0a59cef4 100644 --- a/cli/event_analysis_workflow.py +++ b/cli/event_analysis_workflow.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from typing import Any, Dict, Optional +from typing import Any, Callable, Dict, Optional from tools import extract_search_terms, search_reference_insights from utils.session_manager import SessionManager @@ -23,6 +23,7 @@ def run_event_analysis_workflow( skip_data_collect: bool = False, force_fresh_start: Optional[bool] = None, report_length: Optional[str] = None, + progress_callback: Optional[Any] = None, ) -> str: """Backward-compatible entry: delegates to workflow runner / pipeline.""" return _dispatch_pipeline( @@ -35,6 +36,7 @@ def run_event_analysis_workflow( skip_data_collect=skip_data_collect, force_fresh_start=force_fresh_start, report_length=report_length, + progress_callback=progress_callback, ) @@ -48,6 +50,7 @@ def run_full_report_mode( skip_data_collect: bool = False, force_fresh_start: Optional[bool] = None, report_length: Optional[str] = None, + progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None, ) -> str: """完整报告模式(供 Agent full_report 等复用)。""" return run_event_analysis_workflow( @@ -59,6 +62,7 @@ def run_full_report_mode( skip_data_collect=skip_data_collect, force_fresh_start=force_fresh_start, report_length=report_length, + progress_callback=progress_callback, ) diff --git a/cli/router.py b/cli/router.py index 82ef0d19..ce8647b0 100644 --- a/cli/router.py +++ b/cli/router.py @@ -167,6 +167,10 @@ def recognize(self, query: str) -> IntentResult: # 检查是否明确要求重新搜索 is_re_search = any(p.search(query) for p in self.re_search_patterns) + is_public_opinion_probe = bool( + re.search(r"(舆情|舆论)", query) + and re.search(r"(有什么|有哪些|哪些|如何|怎么样|怎么回事|态势|趋势)", query) + ) hot_keywords = [] for pattern in self.hot_patterns: @@ -194,6 +198,10 @@ def recognize(self, query: str) -> IntentResult: confidence = min(0.7 + 0.1 * len(matched_keywords), 0.95) intent = "event_analysis" reasoning = f"检测到舆情分析关键词: {', '.join(matched_keywords[:3])}" + elif is_public_opinion_probe: + confidence = 0.78 + intent = "event_analysis" + reasoning = "检测到实体舆情探询表达,进入舆情分析流程" else: # 没有明确关键词,默认为一般查询 confidence = 0.5 diff --git a/docs/frontend_next.md b/docs/frontend_next.md new file mode 100644 index 00000000..0447510e --- /dev/null +++ b/docs/frontend_next.md @@ -0,0 +1,56 @@ +# Next.js 前端集成说明 + +Sona 现在保留 Python CLI / Streamlit 入口,并在仓库根新增独立 `frontend/` 作为 Next.js App Router 前端。 + +## 启动 + +终端 1:启动 Python API。 + +```bash +sona serve --host 127.0.0.1 --port 8765 +``` + +终端 2:启动 Next.js。 + +```bash +npm --prefix frontend install +npm --prefix frontend run dev +``` + +前端默认访问 `http://127.0.0.1:3000`。Next.js BFF 读取 `SONA_API_BASE`,默认代理到 `http://127.0.0.1:8765`。可在 `frontend/.env.local` 中覆盖: + +```env +SONA_API_BASE=http://127.0.0.1:8765 +``` + +## `/` 指令迁移 + +前端不直接驱动 CLI 终端循环,而是调用 FastAPI 专用端点: + +| 前端指令 | API | +| --- | --- | +| `/new` | `POST /v1/chat/sessions` | +| `/memory` | `GET /v1/chat/sessions` | +| 普通输入 | `POST /v1/chat/sessions/{task_id}/messages:stream` | +| `/event ` | `POST /v1/analyze-event` | +| `/wiki ` | `POST /v1/wiki/query` | +| `/wiki-approve ` | `POST /v1/wiki/approve` | +| `/case ` | `POST /v1/cases/search` | +| `/hot [config]` | `POST /v1/hot/run` | +| `/monitor list` | `GET /v1/monitor/topics` | +| `/monitor demo` | `POST /v1/monitor/demo` | +| `/monitor create 名称\|领域\|关键词1,关键词2` | `POST /v1/monitor/topics` | +| `/monitor status ` | `GET /v1/monitor/topics/{topic_id}/status` | +| `/monitor report [daily\|weekly]` | `POST /v1/monitor/topics/{topic_id}/report` | +| `/models` | `GET /v1/models` | +| `/tools` | `GET /v1/tools` | + +`/set`、`/compress`、`/clear`、`/exit` 属于 CLI 管理或生命周期指令,前端 v1 不在聊天输入中执行。 + +## 验证 + +```bash +.\.venv\Scripts\python.exe -m pytest tests/contracts/test_frontend_api.py +npm --prefix frontend run lint +npm --prefix frontend run build +``` diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 00000000..5872c3e9 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +SONA_API_BASE=http://127.0.0.1:8765 diff --git a/frontend/app/api/sona/[...path]/route.ts b/frontend/app/api/sona/[...path]/route.ts new file mode 100644 index 00000000..c7078b90 --- /dev/null +++ b/frontend/app/api/sona/[...path]/route.ts @@ -0,0 +1,55 @@ +const DEFAULT_SONA_API_BASE = 'http://127.0.0.1:8765'; + +type RouteContext = { + params: Promise<{ path: string[] }>; +}; + +async function proxy(request: Request, context: RouteContext) { + const { path } = await context.params; + const base = (process.env.SONA_API_BASE || DEFAULT_SONA_API_BASE).replace(/\/+$/, ''); + const incoming = new URL(request.url); + const target = new URL(`${base}/${path.join('/')}`); + target.search = incoming.search; + + const headers = new Headers(request.headers); + headers.delete('host'); + headers.delete('connection'); + headers.delete('content-length'); + + const method = request.method.toUpperCase(); + const body = method === 'GET' || method === 'HEAD' ? undefined : await request.arrayBuffer(); + + try { + const upstream = await fetch(target, { + method, + headers, + body, + cache: 'no-store', + }); + + const responseHeaders = new Headers(upstream.headers); + responseHeaders.delete('content-encoding'); + responseHeaders.delete('transfer-encoding'); + + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: responseHeaders, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown proxy error'; + return Response.json( + { + detail: `Sona API unavailable: ${message}`, + apiBase: base, + }, + { status: 502 }, + ); + } +} + +export const GET = proxy; +export const POST = proxy; +export const PUT = proxy; +export const PATCH = proxy; +export const DELETE = proxy; diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 00000000..28a7d712 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,1135 @@ +:root { + --page: #f4f4f5; + --sidebar: #f7f7f8; + --surface: #ffffff; + --ink: #1f1f22; + --muted: #85858b; + --line: #e6e6e8; + --hover: #ececee; + --active: #e9e9eb; + --accent: #1677ff; + --danger: #e5484d; + --shadow: 0 14px 38px rgba(20, 20, 20, 0.08); + --radius: 10px; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; + margin: 0; + overflow: hidden; + background: var(--page); + color: var(--ink); +} + +body { + font-family: Arial, "Microsoft YaHei", sans-serif; + letter-spacing: 0; +} + +button, +input, +textarea { + font: inherit; +} + +.sonarShell { + display: flex; + height: 100svh; + overflow: hidden; + padding: 8px; + gap: 0; + background: var(--page); +} + +.sonaSideNav { + flex-shrink: 0; + height: 100%; +} + +.sonaSideNavSearch { + display: flex; + align-items: center; + gap: 10px; + margin: 4px 12px 8px; + padding: 0 10px; + min-height: 36px; + border-radius: var(--radius); + color: #8a8a90; +} + +.sonaSideNavSearch:focus-within, +.sonaSideNavSearch:hover { + background: var(--hover); +} + +.sonaSideNavSearch input { + width: 100%; + border: 0; + outline: 0; + background: transparent; + color: var(--ink); + font-size: 14px; +} + +.sonaSideNavSectionLink { + display: block; + width: calc(100% - 16px); + margin: 4px 8px 0; + padding: 6px 12px; + border: 0; + border-radius: var(--radius); + background: transparent; + color: #8a8a90; + cursor: pointer; + font-size: 13px; + text-align: left; +} + +.sonaSideNavSectionLink:hover { + background: var(--hover); + color: var(--ink); +} + +.headerActions button, +.headerTitle button, +.sidebarFoot button { + display: grid; + width: 32px; + height: 32px; + place-items: center; + border: 0; + border-radius: 8px; + background: transparent; + color: #8a8a90; + cursor: pointer; +} + +.headerActions button:hover, +.headerTitle button:hover, +.sidebarFoot button:hover { + background: var(--hover); + color: var(--ink); +} + +.agentIdentity { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; + height: 40px; + padding: 0 8px; + border: 0; + border-radius: var(--radius); + background: transparent; + color: var(--ink); + cursor: pointer; + text-align: left; +} + +.agentIdentity:hover { + background: var(--hover); +} + +.agentIdentity strong { + overflow: hidden; + flex: 1; + font-size: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sonaMark, +.sonaAvatar { + display: inline-grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 50%; + background: #fff; + box-shadow: inset 0 0 0 1px var(--line); + font-size: 20px; + line-height: 1; +} + +.sonaAvatar.user { + font-size: 14px; + font-weight: 600; + color: #4f4f57; +} + +.sonaMark.large { + width: 36px; + height: 36px; + font-size: 26px; +} + +.sidebarNav, +.sidebarSection, +.topicList { + display: grid; + gap: 4px; +} + +.sidebarNav button, +.homeItem, +.sectionTitle { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-height: 40px; + padding: 0 12px; + border: 0; + border-radius: var(--radius); + background: transparent; + color: #696970; + cursor: pointer; + font-size: 15px; + text-align: left; +} + +.sidebarNav button:hover, +.homeItem:hover, +.sectionTitle:hover, +.topicItem:hover { + background: var(--hover); + color: var(--ink); +} + +.statusDot { + display: block; + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; +} + +.statusDot.ok { + color: #1a7f37; +} + +.statusDot.bad { + color: var(--danger); +} + +.searchBox { + display: flex; + align-items: center; + gap: 12px; + min-height: 40px; + padding: 0 12px; + border-radius: var(--radius); + color: #8a8a90; +} + +.searchBox:focus-within, +.searchBox:hover { + background: var(--hover); +} + +.searchBox input { + width: 100%; + border: 0; + outline: 0; + background: transparent; + color: var(--ink); + font-size: 15px; +} + +.sectionTitle { + justify-content: flex-start; + min-height: 32px; + color: #8a8a90; + font-size: 13px; +} + +.sectionTitle span { + margin-left: 0; +} + +.topicList { + min-height: 0; + overflow: auto; + padding-top: 6px; +} + +.topicItem { + display: grid; + grid-template-columns: 22px minmax(0, 1fr) 28px; + gap: 8px; + align-items: center; + width: 100%; + min-height: 42px; + padding: 0 8px 0 12px; + border: 0; + border-radius: var(--radius); + background: transparent; + color: var(--ink); + cursor: pointer; + text-align: left; +} + +.topicItem > span:first-child { + color: #8a8a90; + font-size: 18px; +} + +.topicItem strong { + overflow: hidden; + font-size: 14px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.topicMenu { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border-radius: 8px; + color: #8a8a90; +} + +.topicMenu:hover { + background: #dedee1; + color: var(--ink); +} + +.emptyTopics { + margin: 8px 12px; + color: var(--muted); + font-size: 13px; +} + +.sidebarFoot { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: auto; + padding: 4px 8px 0; + color: var(--muted); + font-size: 12px; +} + +.ok { + color: #1a7f37; +} + +.bad, +.errorText { + color: var(--danger); +} + +.errorText { + margin: 0 10px; + font-size: 12px; + line-height: 1.45; +} + +.conversationPane { + display: flex; + min-width: 0; + min-height: 0; + flex: 1; + height: 100%; + overflow: hidden; + flex-direction: column; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface); +} + +.sonarHeader { + display: flex; + align-items: center; + justify-content: space-between; + height: 48px; + flex: none; + padding: 0 12px 0 18px; + border-bottom: 1px solid var(--line); + background: rgba(255, 255, 255, 0.96); +} + +.headerTitle { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.headerTitle strong { + overflow: hidden; + max-width: 420px; + font-size: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.headerActions { + display: flex; + align-items: center; + gap: 6px; +} + +.workspaceBody { + display: flex; + min-height: 0; + flex: 1; + overflow: hidden; +} + +.conversationStage { + display: flex; + position: relative; + min-width: 0; + min-height: 0; + flex: 1; + flex-direction: column; + overflow: hidden; +} + +.conversationScroll { + min-height: 0; + flex: 1; + overflow: auto; +} + +.conversationScroll.isLoading .chatCanvas, +.conversationScroll.isLoading .homeCanvas { + min-height: 240px; +} + +.sessionLoadingBar { + display: flex; + justify-content: center; + padding: 24px 0 8px; +} + +.homeCanvas, +.chatCanvas { + width: min(100%, 1084px); + margin: 0 auto; + padding: 48px 24px 24px; +} + +.chatCanvas { + width: min(100%, 1080px); + padding-top: 76px; +} + +.chatCanvas .lobe-flex { + max-width: 100%; +} + +.chatCanvas time { + color: var(--muted); + font-size: 12px; +} + +.sonaChatThread { + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; +} + +.sonaChatRow { + display: flex; + flex-direction: column; + gap: 8px; + max-width: min(100%, 760px); +} + +.sonaChatRow.isUser { + align-self: flex-end; + align-items: flex-end; +} + +.sonaChatRow.isAssistant { + align-self: flex-start; + align-items: flex-start; +} + +.sonaChatRowHead { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 12px; +} + +.sonaChatRowHead strong { + color: var(--ink); + font-size: 13px; + font-weight: 600; +} + +.sonaChatRowAvatar { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border-radius: 50%; + background: #fff; + box-shadow: inset 0 0 0 1px var(--line); + font-size: 14px; + line-height: 1; +} + +.sonaChatBubble { + width: 100%; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 14px; + background: #fff; + color: var(--ink); + font-size: 15px; + line-height: 1.65; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.sonaChatRow.isUser .sonaChatBubble { + border-color: #c8d6f0; + background: #edf4ff; +} + +.sonaChatRow.isAssistant .sonaChatBubble { + background: #fafafa; +} + +.sonaChatRow.isAssistant .sonaChatBubble.sonaChatAnswer { + white-space: normal; +} + +.sonaChatAnswer { + margin-top: 0; +} + +.sonaChatMarkdown { + width: 100%; +} + +.sonaChatAnswerBody { + display: grid; + gap: 10px; +} + +.sonaChatAnswerText { + margin: 0; + white-space: pre-wrap; +} + +.sonaReportFileCard { + width: min(100%, 420px); + border-radius: 12px; + background: #fff; +} + +.sonaAgentSteps { + width: min(100%, 760px); + margin-bottom: 8px; + padding: 8px 10px; + border: 1px solid #e8e8ec; + border-radius: 10px; + background: #f8f9fb; +} + +.sonaAgentSteps summary { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + color: #6b6b73; + font-size: 13px; + font-weight: 600; + list-style: none; +} + +.sonaAgentSteps summary::-webkit-details-marker { + display: none; +} + +.sonaAgentStepsCount { + padding: 2px 8px; + border-radius: 999px; + background: #ececf0; + color: #4f4f57; + font-size: 11px; + font-weight: 500; +} + +.sonaAgentStepsList { + display: grid; + gap: 8px; + margin: 10px 0 0; + padding: 0; + list-style: none; +} + +.sonaAgentStep { + padding: 8px 10px; + border-radius: 8px; + background: #fff; + border: 1px solid #ececf0; +} + +.sonaAgentStepTitle { + display: block; + margin-bottom: 6px; + color: #4f4f57; + font-size: 12px; + font-weight: 600; +} + +.sonaAgentStep pre { + margin: 0; + max-height: 200px; + overflow: auto; + padding: 8px; + border-radius: 6px; + background: #17181c; + color: #e8edf5; + font-size: 11px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +.sonaAgentStep--thinking .sonaAgentStepTitle { + color: #7c5cff; +} + +.sonaAgentStep--tool .sonaAgentStepTitle { + color: #b45309; +} + +.sonaAgentStep--approval .sonaAgentStepTitle { + color: #b45309; +} + +.sonaApprovalPanel { + width: min(100%, 760px); + padding: 12px; + border: 1px solid #f0d39a; + border-radius: 8px; + background: #fffaf0; +} + +.sonaApprovalHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.sonaApprovalHeader strong { + color: #78350f; + font-size: 14px; +} + +.sonaApprovalHeader span { + flex-shrink: 0; + color: #9a5b12; + font-size: 12px; +} + +.sonaApprovalPanel pre { + max-height: 260px; + margin: 0; + overflow: auto; + color: #2f2f36; + font-size: 12px; + line-height: 1.55; + white-space: pre-wrap; +} + +.sonaApprovalActions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.sonaAgentStep--workflow .sonaAgentStepTitle { + color: #0d9488; +} + +#lobe-ui-theme-app .sonaChatBubble p { + margin: 0 0 0.75em; + line-height: 1.65; +} + +#lobe-ui-theme-app .sonaChatBubble p:last-child { + margin-bottom: 0; +} + +.homeHero { + max-width: 1084px; + margin: 0 auto; +} + +.heroAgent { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 20px; +} + +.heroAgent strong { + font-size: 22px; +} + +.homeHero h1, +.homeHero p { + margin: 0; + font-size: 18px; + line-height: 1.7; +} + +.homeHero p { + margin-bottom: 28px; +} + +.homeShortcuts { + display: flex; + justify-content: center; + gap: 10px; + margin: 18px 0 64px; +} + +.homeShortcuts button, +.briefHead button { + border: 1px solid var(--line); + border-radius: 999px; + background: #fff; + color: #59595f; + cursor: pointer; +} + +.homeShortcuts button { + height: 36px; + padding: 0 16px; + box-shadow: 0 2px 8px rgba(20, 20, 20, 0.04); +} + +.homeShortcuts button:hover, +.briefHead button:hover { + border-color: #c8d6f0; + color: var(--accent); +} + +.briefList { + display: grid; + gap: 10px; + width: min(100%, 1084px); +} + +.briefHead { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; + color: #77777d; +} + +.briefHead strong { + font-size: 16px; +} + +.briefHead button { + height: 30px; + padding: 0 12px; +} + +.briefList > button { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + min-height: 112px; + padding: 18px; + border: 1px solid var(--line); + border-radius: 14px; + background: #fff; + color: var(--ink); + cursor: pointer; + text-align: left; +} + +.briefList > button:hover { + border-color: #d2d8e5; + box-shadow: 0 8px 28px rgba(20, 20, 20, 0.06); +} + +.briefList span { + font-size: 18px; + font-weight: 600; +} + +.briefList small { + grid-column: 2 / -1; + color: var(--muted); + font-size: 14px; +} + +.chatEmpty { + display: grid; + min-height: 360px; + place-items: center; + align-content: center; + gap: 10px; +} + +.chatEmpty strong { + font-size: 20px; +} + +.chatEmptyHint { + max-width: 420px; + margin: 0; + color: var(--muted); + font-size: 14px; + line-height: 1.6; + text-align: center; +} + +.chatEmptyError { + max-width: 420px; + margin: 8px 0 0; + color: var(--danger); + font-size: 13px; + line-height: 1.5; + text-align: center; +} + +.streaming { + width: fit-content; + margin: 8px 0 0 50px; + color: var(--muted); +} + +/* LobeHub 主界面输入框 — https://app.lobehub.com/ */ +.sonaChatComposerDock { + z-index: 3; + flex: none; + padding: 0 16px 20px; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0.94) 28%, + #fff 100% + ); +} + +.sonaChatComposerShell { + width: min(960px, 100%); +} + +.sonaChatComposerCard { + display: flex; + flex-direction: column; + min-height: 150px; + overflow: hidden; + border: 1px solid #e3e3e8; + border-radius: 16px; + background: #fff; + box-shadow: + 0 0 0 1px rgba(15, 23, 42, 0.02), + 0 10px 36px rgba(15, 23, 42, 0.08); +} + +.sonaChatComposerEditor { + width: 100%; + min-height: 120px; + flex: 1; + padding: 16px 16px 8px; + border: 0; + outline: 0; + resize: none; + background: transparent; + color: var(--ink); + font-size: 15px; + line-height: 1.6; +} + +.sonaChatComposerEditor::placeholder { + color: #9b9ba4; +} + +.sonaChatComposerEditor:focus { + outline: none; +} + +.sonaChatComposerFoot { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; + padding: 4px 12px 12px; +} + +.sonaChatComposerFootLeft { + min-width: 0; + flex: 1; +} + +.sonaChatComposerFootRight { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 10px; +} + +.sonaChatComposerHint { + color: #9b9ba4; +} + +.sonaSendButton.ant-btn { + --send-button-size: 32px; + width: 32px; + height: 32px; + min-width: 32px; + padding: 0; + border-radius: 50%; + box-shadow: none; +} + +.sonaSendButton.ant-btn .ant-btn-icon { + display: grid; + place-items: center; + font-size: 16px; +} + +.sonaSendButton.ant-btn:not(:disabled):hover { + filter: brightness(1.04); +} + +.sonaToolChip.ant-btn { + height: 28px; + padding-inline: 10px; + border: 1px solid #e8e8ec; + border-radius: 8px; + background: #fafafa; + color: #4f4f57; + font-size: 12px; +} + +.sonaToolChip.ant-btn:hover { + border-color: #d8dee8; + background: #f3f5f8 !important; + color: #111116 !important; +} + +@media (max-width: 720px) { + .sonaChatComposerHint { + display: none; + } + + .sonaChatComposerFoot { + flex-wrap: wrap; + } +} + +.topicPanel { + width: 360px; + min-width: 360px; + height: 100%; + overflow: auto; + border-left: 1px solid var(--line); + background: #fafafa; + transition: width 180ms ease, min-width 180ms ease, opacity 160ms ease; +} + +.topicPanel.closed { + width: 0; + min-width: 0; + opacity: 0; + overflow: hidden; + pointer-events: none; +} + +.topicPanel > .ant-segmented { + margin: 12px; + width: calc(100% - 24px); +} + +.panel { + padding: 4px 16px 16px; +} + +.panelTitle, +.quickActions { + display: flex; + align-items: center; + gap: 8px; +} + +.taskList, +.listPanel { + display: grid; + gap: 8px; + margin-top: 12px; +} + +.quickActions { + margin-top: 12px; +} + +.taskList button { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px; + width: 100%; + padding: 10px; + border: 1px solid var(--line); + border-radius: 10px; + background: #fff; + color: var(--ink); + cursor: pointer; + text-align: left; +} + +.taskList button:hover { + background: var(--hover); +} + +.taskList button small { + grid-column: 1 / -1; + line-height: 1.45; +} + +.muted, +.lineItem small { + color: var(--muted); + font-size: 12px; +} + +.reportFrame { + width: 100%; + height: 460px; + margin-top: 14px; + border: 1px solid var(--line); + border-radius: 10px; + background: white; +} + +.lineItem { + display: grid; + gap: 4px; + padding: 10px 0; + border-bottom: 1px solid var(--line); +} + +.profileSummary { + display: flex; + gap: 8px; + margin: 12px 0 4px; +} + +.profileSummary span { + padding: 4px 9px; + border: 1px solid var(--line); + border-radius: 999px; + background: #fff; + color: var(--muted); + font-size: 12px; +} + +.lineItem span { + color: var(--muted); + font-size: 13px; + line-height: 1.45; +} + +.activityPanel { + display: grid; + gap: 8px; + margin: 12px 16px 0; + padding-bottom: 12px; + border-bottom: 1px solid var(--line); +} + +.activityItem { + display: grid; + gap: 4px; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.activityItem strong { + color: var(--ink); + font-size: 13px; +} + +.activityItem span { + max-height: 84px; + overflow: auto; + white-space: pre-wrap; +} + +.jsonBox { + min-height: 260px; + overflow: auto; + padding: 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: #17181c; + color: #edf2f7; + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; +} + +@media (max-width: 1180px) { + .topicPanel { + position: absolute; + z-index: 30; + right: 8px; + top: 56px; + height: calc(100svh - 64px); + border: 1px solid var(--line); + border-radius: 10px; + box-shadow: -12px 0 30px rgba(15, 23, 42, 0.12); + } +} + +@media (max-width: 760px) { + .sonarShell { + padding: 0; + } + + .sonaSideNav { + display: none; + } + + .conversationPane { + border: 0; + border-radius: 0; + } + + .homeCanvas, + .chatCanvas { + padding: 44px 12px 16px; + } + + .sonaChatComposerDock { + padding-inline: 10px; + padding-block-end: 12px; + } + + .topicPanel.open { + right: 0; + width: min(100vw, 360px); + min-width: min(100vw, 360px); + } +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 00000000..7ed0ce8e --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,27 @@ +import 'antd/dist/reset.css'; +import './globals.css'; + +import { AntdRegistry } from '@ant-design/nextjs-registry'; +import type { Metadata } from 'next'; +import { Providers } from './providers'; + +export const metadata: Metadata = { + title: 'Sona', + description: 'Sona chat workspace', +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + {children} + + + + ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 00000000..7135c299 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from 'react'; +import { SonaWorkspace } from '@/features/workspace/SonaWorkspace'; + +export default function Home() { + return ( + + + + ); +} diff --git a/frontend/app/providers.tsx b/frontend/app/providers.tsx new file mode 100644 index 00000000..5478c2b1 --- /dev/null +++ b/frontend/app/providers.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { ThemeProvider } from '@lobehub/ui'; +import { useEffect, useState } from 'react'; + +/** + * Lobe ThemeProvider uses antd-style/emotion; SSR markup order differs from the client. + * Mount theme only after hydration to avoid mismatch (see ant-app vs emotion global style). + */ +export function Providers({ children }: Readonly<{ children: React.ReactNode }>) { + const [themeReady, setThemeReady] = useState(false); + + useEffect(() => { + setThemeReady(true); + }, []); + + if (!themeReady) { + return ( +
+ {children} +
+ ); + } + + return ( + + {children} + + ); +} diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 00000000..c84747a5 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,6 @@ +import nextVitals from 'eslint-config-next/core-web-vitals'; +import nextTs from 'eslint-config-next/typescript'; + +const eslintConfig = [...nextVitals, ...nextTs]; + +export default eslintConfig; diff --git a/frontend/features/workspace/SonaChatAnswer.tsx b/frontend/features/workspace/SonaChatAnswer.tsx new file mode 100644 index 00000000..d9425a67 --- /dev/null +++ b/frontend/features/workspace/SonaChatAnswer.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { parseAnswerSegments, hasReportRefs } from '@/features/workspace/reportRefs'; +import { SonaMarkdown } from '@/features/workspace/SonaMarkdown'; +import { SonaReportFileCard } from '@/features/workspace/SonaReportFileCard'; + +type SonaChatAnswerProps = { + answer: string; + currentTaskId?: string; + onOpenReport?: (taskId: string) => void; + streaming?: boolean; +}; + +export function SonaChatAnswer({ + answer, + currentTaskId, + onOpenReport, + streaming, +}: SonaChatAnswerProps) { + if (!hasReportRefs(answer)) { + return ; + } + + const segments = parseAnswerSegments(answer, currentTaskId); + + return ( +
+ {segments.map((segment, index) => { + if (segment.kind === 'text') { + return ( + + ); + } + return ( + + ); + })} +
+ ); +} diff --git a/frontend/features/workspace/SonaChatComposer.tsx b/frontend/features/workspace/SonaChatComposer.tsx new file mode 100644 index 00000000..9edad7f2 --- /dev/null +++ b/frontend/features/workspace/SonaChatComposer.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { useMemo, useRef } from 'react'; +import { Dropdown } from 'antd'; +import { Button, Flexbox, Text, Tooltip } from '@lobehub/ui'; +import { ArrowBigUp, ChevronDown, CornerDownLeft, SendHorizontal } from 'lucide-react'; +import type { ComposerCommand } from '@/types/sona'; +import { sortComposerCommands } from './sonaToolUi'; + +const VISIBLE_COMMAND_COUNT = 5; +const COMPOSER_MIN_HEIGHT = 120; + +type SonaChatComposerProps = { + busy?: boolean; + commands: ComposerCommand[]; + onCommand: (command: ComposerCommand) => void; + onInput: (value: string) => void; + onSend: () => void; + value: string; +}; + +function CommandStrip({ + commands, + onCommand, +}: Readonly<{ + commands: ComposerCommand[]; + onCommand: (command: ComposerCommand) => void; +}>) { + const sorted = useMemo(() => sortComposerCommands(commands), [commands]); + const visible = sorted.slice(0, VISIBLE_COMMAND_COUNT); + const overflow = sorted.slice(VISIBLE_COMMAND_COUNT); + + if (!sorted.length) { + return null; + } + + return ( + + {visible.map((command) => ( + + + + ))} + {overflow.length ? ( + ({ + key: command.id, + label: command.label, + title: command.description, + onClick: () => onCommand(command), + })), + }} + trigger={['click']} + > + + + ) : null} + + ); +} + +function ComposerHint() { + return ( + + + + 发送 + + + / + + + + + + + Ctrl+Enter 换行 + + + ); +} + +export function SonaChatComposer({ + busy = false, + commands, + onCommand, + onInput, + onSend, + value, +}: Readonly) { + const isComposingRef = useRef(false); + const canSend = busy || Boolean(value.trim()); + + function handleKeyDown(event: React.KeyboardEvent) { + if (isComposingRef.current) return; + if (event.key !== 'Enter') return; + if (event.ctrlKey || event.metaKey) return; + if (event.shiftKey) return; + event.preventDefault(); + if (canSend) onSend(); + } + + return ( +
+ +
+