281 lines
11 KiB
Python
Raw 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.

#!/usr/bin/env python3
"""探索工具 server 的只读取数实现(阶段 D)。
边界合同中的工具 server 组件:给创作智能体提供只读取数能力的统一入口。
每个工具声明读什么表、什么版本语义、返回什么结构;不提供裸查询,不写库。
连接固定 `muse_db.connect(readonly=True)`(会话级只读,写语句被库直接拒)。
查询语义与 assemble-context 的三个一等取数端保持一致(按边界合同工具 server
自持实现,不跨 Skill 导入):已确认细纲、已确认文风投影、已确认范式绑定;
另加正文章节读取与实体检索两个探索工具。
CLI:`read_tools.py <工具名> '<json 参数>'`,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 <工具名> '<json 参数>' | --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"]