feat: 对话层 Agent 化(对话底座 + 自研 skill)
把对话页从固定状态机换成 skill 驱动的 Agent:顾问说什么都原样发给 agent, 由 SKILL.md + 工具决定该聊还是该调工具。工作台(workbenchView)未改动。 后端 - llm.py:新增 stream_chat()(流式 + tools),并带 reasoning 端点自动降级 (gemini-3.7-flash 拒绝 effort:none,首次 400 后锁定重试) - agent.py:最小对话底座内核——skill 注入 + 工具循环 + SSE 事件 - tools.py:8 个工具包住既有能力(analyze/diagnose/scaffold/reference/ translate/recheck/record_note/confirm),延迟 import main 复用已验证的 endpoint 处理器与测试接缝,prompts.py/schemas.py/evidence.py 未改一行 - main.py:POST /api/chat/stream(SSE) skill - skills/hvr-rewrite/SKILL.md:分诊规则、首轮盘点、AI 味清单对齐 blader/humanizer 的 A–E 分类(含强度校准与反误报)、诚实性硬约束 前端 - 流式正文 + 工具卡片 + 选项按钮;工具 payload 直接喂既有渲染函数 (renderUnderstanding/renderDiagnosis/renderRecheck),没有第二套 UI - 删除随 agent 化失效的 runDiagnose / applyUnderstandingCorrection 死代码 验证(真实模型,非 mock) - 65 单测全绿(test_agent.py 30 + test_demo.py 35) - 逐轮实跑:盘点 → 确认 → 诊断 → 复检,SSE 事件序列与 payload kind 均符合契约 - 前端 readSSE 用真实响应字节按 7/64/全量三种切块回放,事件序列一致 - 实测修掉两个只在真跑时暴露的问题:模型调完分析直接调确认工具导致正文为空 (SKILL.md 补「正文先行」硬规则);伪标题 `**N. 标题**` 让列表判定失败、 短横线漏成字面字符(前端改逐行分组渲染) 未验证:浏览器人工走查(无浏览器自动化环境),仅到「真实 HTTP + 真实字节回放」这一层。
This commit is contained in:
@@ -6,12 +6,15 @@ OpenRouter pipeline. Run via ./local_start.sh.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Callable, Iterator
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
import agent
|
||||
from evidence import sanitize_diagnosis
|
||||
from llm import LLMError, LlmClient
|
||||
from prompts import (
|
||||
@@ -246,6 +249,57 @@ def _translate_sentences(req: TranslateRequest) -> TranslateResponse:
|
||||
return TranslateResponse(paragraph_id=req.paragraph_id, sentences=pairs)
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
"""对话页的一轮输入。
|
||||
|
||||
业务上下文(原文/改写稿/顾问要求)每轮重发:顾问可能刚在工作台改完一段,
|
||||
服务端不能假设自己手上那份还是最新的。session 里保留的是分析/诊断结果。"""
|
||||
|
||||
session_id: str | None = None
|
||||
message: str = ""
|
||||
prompt: str = ""
|
||||
word_limit: int | None = None
|
||||
paragraphs: list[str] = []
|
||||
constraints: list[str] = []
|
||||
rewrite_paragraphs: list[str] = []
|
||||
confirmed_anchors: list[str] = []
|
||||
paragraph_constraints: dict[str, list[str]] = {}
|
||||
# 只有新会话的第一轮会带:页面刷新/进程重启后服务端手里没有已完成的结果,
|
||||
# 缺了它们复检会静默降级(少 Pattern 对照、锚点为空)。老会话重发是纯浪费。
|
||||
analysis: dict | None = None
|
||||
diagnosis: dict | None = None
|
||||
|
||||
|
||||
def _sse(name: str, data: dict[str, Any]) -> str:
|
||||
return f"event: {name}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
@app.post("/api/chat/stream")
|
||||
def chat_stream(req: ChatMessage) -> StreamingResponse:
|
||||
"""对话页唯一入口(SSE)。事件契约见 docs/Agent化改造方案_20260911.md §四。
|
||||
|
||||
用同步生成器:StreamingResponse 会把它丢进线程池,与本文件其余同步端点一致。
|
||||
事件里的异常一律翻成 error 帧而不是 500 —— 流已经开了,HTTP 状态码改不了,
|
||||
前端只能靠 error 帧知道失败。"""
|
||||
sess = agent.get_session(req.session_id, req.model_dump())
|
||||
|
||||
def gen() -> Iterator[str]:
|
||||
try:
|
||||
for ev in agent.run_agent_stream(sess, req.message, get_client()):
|
||||
yield _sse(ev["event"], ev["data"])
|
||||
except LLMError as exc:
|
||||
yield _sse("error", {"code": "llm_error", "msg": str(exc)})
|
||||
except Exception as exc: # 兜底:不让未预期异常变成半截流
|
||||
yield _sse("error", {"code": "internal", "msg": f"{exc.__class__.__name__}:{exc}"})
|
||||
|
||||
return StreamingResponse(
|
||||
gen(),
|
||||
media_type="text/event-stream",
|
||||
# no-store + X-Accel-Buffering:中间层缓冲会把流攒成一坨再吐,流式就白做了
|
||||
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index() -> FileResponse:
|
||||
return FileResponse(HERE / "index.html", media_type="text/html")
|
||||
|
||||
Reference in New Issue
Block a user