#!/usr/bin/env python3 """探索工具 server 的只读取数实现(阶段 D)。 边界合同中的工具 server 组件:给创作智能体提供只读取数能力的统一入口。 每个工具声明读什么表、什么版本语义、返回什么结构;不提供裸查询,不写库。 连接固定 `muse_db.connect(readonly=True)`(会话级只读,写语句被库直接拒)。 查询语义与 assemble-context 的三个一等取数端保持一致(按边界合同工具 server 自持实现,不跨 Skill 导入):已确认细纲、已确认文风投影、已确认范式绑定; 另加正文章节读取与实体检索两个探索工具。 CLI:`read_tools.py <工具名> ''`,JSON 结果输出到 stdout; 未知工具或参数非法退出码 2(合同拒绝),运行时错误退出码 1。 `--list` 输出工具登记表(名称、描述、读取表),供扩展与测试对账。 """ from __future__ import annotations import json import sys from typing import Any, Callable, Mapping import muse_db # 返回规模上限:防御性裁剪,避免单次工具返回撑爆模型上下文。 MAX_TEXT_CHARS = 30000 MAX_ROWS = 50 MAX_DESC_CHARS = 500 def _payload(value: Any) -> Any: """规划行 payload 统一解成对象(库里可能是 jsonb 或字符串)。""" if isinstance(value, str): return json.loads(value) return value def _style_constraints(style_payload: Any) -> list[str]: """把文风投影成约束字符串列表(与 assemble-context 口径一致)。""" if isinstance(style_payload, str): text = style_payload.strip() return [text] if text else [] if isinstance(style_payload, dict): rules = [] for aspect, value in style_payload.items(): text = str(value).strip() if text: rules.append(f"{aspect}:{text}") return rules return [] def _require_int(args: Mapping[str, Any], name: str) -> int: value = args.get(name) if isinstance(value, bool) or not isinstance(value, int): raise ValueError(f"参数 {name} 必须是整数") return value def read_fine_outline(conn, args: Mapping[str, Any]) -> dict[str, Any]: """读指定章最新一条已确认细纲(state=confirmed,version 倒序)。""" work_id = _require_int(args, "work_id") target_chapter = _require_int(args, "target_chapter") row = conn.execute( "SELECT payload, version FROM example_planning_section WHERE work_id=%s AND " "section_type='fine_outline' AND target_chapter=%s AND state='confirmed' AND " "deleted=false ORDER BY version DESC LIMIT 1", (work_id, target_chapter), ).fetchone() if not row: return {"found": False, "reason": f"无第{target_chapter}章已确认细纲"} return {"found": True, "version": row[1], "payload": _payload(row[0])} def read_style_constraints(conn, args: Mapping[str, Any]) -> dict[str, Any]: """读已确认文风并投影为约束;优先独立 style 行,回退设定行 style 字段。""" work_id = _require_int(args, "work_id") row = conn.execute( "SELECT payload FROM example_planning_section WHERE work_id=%s AND " "schema_type='style' AND state='confirmed' AND deleted=false " "ORDER BY version DESC LIMIT 1", (work_id,), ).fetchone() if row: return {"constraints": _style_constraints(_payload(row[0]))} setting = conn.execute( "SELECT payload FROM example_planning_section WHERE work_id=%s AND " "section_type='setting' AND state='confirmed' AND deleted=false " "ORDER BY version DESC LIMIT 1", (work_id,), ).fetchone() if setting: payload = _payload(setting[0]) if isinstance(payload, dict): return {"constraints": _style_constraints(payload.get("style"))} return {"constraints": []} def read_pattern_bindings(conn, args: Mapping[str, Any]) -> dict[str, Any]: """读最新一条已确认 assembly 规划行的 patternReferences;无绑定诚实返空。""" work_id = _require_int(args, "work_id") row = conn.execute( "SELECT payload FROM example_planning_section WHERE work_id=%s AND " "section_type='assembly' AND state='confirmed' AND deleted=false " "ORDER BY version DESC LIMIT 1", (work_id,), ).fetchone() if not row: return {"bindings": []} payload = _payload(row[0]) if not isinstance(payload, dict): return {"bindings": []} references = payload.get("patternReferences", []) return {"bindings": [dict(item) for item in references if isinstance(item, dict)]} def read_chapter_text(conn, args: Mapping[str, Any]) -> dict[str, Any]: """读指定章正式正文(章节行 + 正文块按序拼接);超长截断并标记。""" work_id = _require_int(args, "work_id") chapter_order = _require_int(args, "chapter_order") chapter = conn.execute( "SELECT c.id, c.order_no, c.title FROM muse_content_chapter c " "WHERE c.work_id=%s AND c.order_no=%s AND c.deleted=false LIMIT 1", (work_id, chapter_order), ).fetchone() if not chapter: return {"found": False, "reason": f"无第{chapter_order}章(work={work_id})"} blocks = conn.execute( "SELECT b.content_text FROM muse_content_block b " "WHERE b.chapter_id=%s AND b.deleted=false ORDER BY b.order_no", (chapter[0],), ).fetchall() text = "\n".join(str(block[0] or "") for block in blocks) truncated = len(text) > MAX_TEXT_CHARS return { "found": True, "chapterOrder": chapter[1], "title": chapter[2], "text": text[:MAX_TEXT_CHARS], "truncated": truncated, "totalChars": len(text), } def search_entities(conn, args: Mapping[str, Any]) -> dict[str, Any]: """按名称模糊检索作品实体(人物、势力、地点、力量体系等);结果有界。""" work_id = _require_int(args, "work_id") keyword = str(args.get("keyword") or "").strip() if not keyword: raise ValueError("参数 keyword 不能为空") entity_type = args.get("entity_type") if entity_type is not None and not isinstance(entity_type, str): raise ValueError("参数 entity_type 必须是字符串") query = ( "SELECT entity_type, normalized_name, description, status " "FROM muse_knowledge_entity WHERE work_id=%s AND deleted=false " "AND normalized_name ILIKE %s" ) params: list[Any] = [work_id, f"%{keyword}%"] if entity_type: query += " AND entity_type=%s" params.append(entity_type) query += " ORDER BY normalized_name LIMIT %s" params.append(MAX_ROWS) rows = conn.execute(query, tuple(params)).fetchall() return { "entities": [ { "entityType": row[0], "name": row[1], "description": str(row[2] or "")[:MAX_DESC_CHARS], "status": row[3], } for row in rows ] } # 工具登记表:名称 -> (描述、读取表、参数、实现)。扩展与测试以此对账,保证单一事实源。 # args 是参数说明:{参数名: 类型},类型取 int / str;可选参数名后加 ?。 TOOL_REGISTRY: dict[str, dict[str, Any]] = { "read_fine_outline": { "description": "读指定作品指定章的最新一条已确认细纲(结构骨架与硬约束)。", "tables": ("example_planning_section",), "args": {"work_id": "int", "target_chapter": "int"}, "impl": read_fine_outline, }, "read_style_constraints": { "description": "读作品已确认文风并投影为约束列表。", "tables": ("example_planning_section",), "args": {"work_id": "int"}, "impl": read_style_constraints, }, "read_pattern_bindings": { "description": "读作品已确认的范式绑定(规划期选定,写作期只消费)。", "tables": ("example_planning_section",), "args": {"work_id": "int"}, "impl": read_pattern_bindings, }, "read_chapter_text": { "description": "读指定章的正式正文全文(超长截断并标记)。", "tables": ("muse_content_chapter", "muse_content_block"), "args": {"work_id": "int", "chapter_order": "int"}, "impl": read_chapter_text, }, "search_entities": { "description": "按名称模糊检索作品实体(人物、势力、地点、力量体系等)。", "tables": ("muse_knowledge_entity",), "args": {"work_id": "int", "keyword": "str", "entity_type?": "str"}, "impl": search_entities, }, } def execute_tool(name: str, args: Mapping[str, Any], connect: Callable[..., Any] | None = None) -> dict[str, Any]: """执行一个登记工具;只读连接,未知工具拒绝。""" entry = TOOL_REGISTRY.get(name) if entry is None: raise KeyError(f"未知探索工具: {name}") factory = connect or (lambda: muse_db.connect(readonly=True)) conn = factory() try: return entry["impl"](conn, args) finally: closer = getattr(conn, "close", None) if callable(closer): closer() def main(argv: list[str]) -> int: if argv and argv[0] == "--list": listing = { name: { "description": entry["description"], "tables": list(entry["tables"]), "args": entry.get("args", {}), } for name, entry in TOOL_REGISTRY.items() } print(json.dumps(listing, ensure_ascii=False, indent=1)) return 0 if len(argv) != 2: print("用法: read_tools.py <工具名> '' | --list", file=sys.stderr) return 2 name, raw_args = argv if name not in TOOL_REGISTRY: print(f"未知探索工具: {name}", file=sys.stderr) return 2 try: args = json.loads(raw_args) except json.JSONDecodeError: print("参数不是合法 JSON", file=sys.stderr) return 2 if not isinstance(args, dict): print("参数必须是 JSON 对象", file=sys.stderr) return 2 try: result = execute_tool(name, args) except ValueError as exc: print(f"参数合同拒绝: {exc}", file=sys.stderr) return 2 except Exception as exc: # 运行时失败如实上报,不静默 print(f"工具执行失败: {type(exc).__name__}: {exc}", file=sys.stderr) return 1 print(json.dumps(result, ensure_ascii=False)) return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:])) __all__ = ["TOOL_REGISTRY", "execute_tool", "main"]