assistant/backend/hub_sync.py

140 lines
5.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""与中心服务端同步经验:上报本校草稿、拉取中心审核发布的结果。
两条原则:
1. 永远不影响办事。同步全部在后台跑,失败只记一行日志,任务该怎么跑还怎么跑。
2. 不配 HUB_URL / HUB_TOKEN 就整个关掉,桌面端单机照常工作。
拉回来的条目直接落成本地 verified(来源记 hub),下次执行同类业务时注入提示词;
中心驳回的条目在本地也置为 rejected,避免本地自动晋升过的错知识继续生效。
"""
from __future__ import annotations
import asyncio
import logging
import httpx
from backend.config import settings
from backend.db import database as db
log = logging.getLogger(__name__)
VERSION_KEY = "hub_pulled_version"
_LAST_ERROR_KEY = "hub_last_error"
def enabled() -> bool:
return bool(settings.hub_url and settings.hub_token)
def _client() -> httpx.AsyncClient:
return httpx.AsyncClient(
base_url=settings.hub_url.rstrip("/"),
headers={"Authorization": f"Bearer {settings.hub_token}"},
timeout=settings.hub_timeout_seconds,
)
def _note_error(action: str, error: Exception) -> None:
text = f"{action} 失败:{type(error).__name__}: {str(error)[:160]}"
log.warning("[hub] %s", text)
try:
db.set_setting(_LAST_ERROR_KEY, text)
except Exception: # noqa: BLE001 连本地库都挂了就别再添乱
pass
async def report_drafts(knowledge_id: str | None = None, business_id: str | None = None) -> dict:
"""把本校还没被中心处理过的草稿报上去。中心按内容判重,重复上报是安全的。"""
if not enabled():
return {"skipped": "未配置中心服务端"}
# 草稿要报(让中心看到队列),本地经试用升级的也要报——那是最硬的证据,中心据此可以推给别的学校
drafts = db.list_learned(knowledge_id=knowledge_id, business_id=business_id, status="draft")
proven = [
r for r in db.list_learned(knowledge_id=knowledge_id, business_id=business_id, status="verified")
if r.get("verified_by") == "trial"
]
rows = drafts + proven
if not rows:
return {"reported": 0}
payload = [
{
"knowledge_id": r["knowledge_id"],
"business_id": r["business_id"] or "",
"key": r["key"],
"value": r["value"],
"category": r["category"],
"observations": int(r["observations"] or 1),
"source_task_id": r.get("source_task_id") or "",
# 试用证据:Agent 在真实页面上验过几次。比观察次数硬得多,中心据此判断可信度
"confirmed": int(r.get("confirmed") or 0),
"contradicted": int(r.get("contradicted") or 0),
"status": r.get("status") or "draft",
}
for r in rows
]
try:
async with _client() as client:
resp = await client.post("/sync/report", json={"school_id": settings.school_id, "entries": payload})
resp.raise_for_status()
result = resp.json()
log.info("[hub] 上报 %d 条草稿:%s", len(payload), result)
return {"reported": len(payload), **result}
except Exception as e: # noqa: BLE001
_note_error("上报草稿", e)
return {"error": str(e)[:200]}
async def pull_published() -> dict:
"""拉取中心审核后的结果,落成本地 verified / rejected。按版本号增量。"""
if not enabled():
return {"skipped": "未配置中心服务端"}
since = db.get_setting(VERSION_KEY, "0")
since_version = int(since) if str(since).isdigit() else 0
try:
async with _client() as client:
resp = await client.get("/sync/published", params={"school_id": settings.school_id, "since": since_version})
resp.raise_for_status()
data = resp.json()
except Exception as e: # noqa: BLE001
_note_error("拉取已发布", e)
return {"error": str(e)[:200]}
entries = data.get("entries") or []
applied = db.apply_hub_entries(entries)
if entries:
db.set_setting(VERSION_KEY, str(max(int(e.get("version") or 0) for e in entries)))
log.info("[hub] 拉取 %d 条,应用 %s", len(entries), applied)
return {"pulled": len(entries), **applied, "hub_version": data.get("version")}
async def sync_once() -> dict:
"""先报后拉:刚沉淀的草稿也能在这一轮被中心看到。"""
report = await report_drafts()
pull = await pull_published()
return {"report": report, "pull": pull}
async def sync_loop(interval_seconds: int) -> None:
if not enabled():
log.info("[hub] 未配置 HUB_URL / HUB_TOKEN,中心同步关闭")
return
log.info("[hub] 中心同步已开启:%s,每 %d 秒一次", settings.hub_url, interval_seconds)
while True:
try:
await sync_once()
except Exception as e: # noqa: BLE001 循环不能被任何异常打断
_note_error("定时同步", e)
await asyncio.sleep(max(60, interval_seconds))
def status() -> dict:
return {
"enabled": enabled(),
"url": settings.hub_url,
"school_id": settings.school_id,
"pulled_version": db.get_setting(VERSION_KEY, "0") if enabled() else "0",
"last_error": db.get_setting(_LAST_ERROR_KEY, "") if enabled() else "",
}