Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

619 lines
20 KiB
Python

import asyncio
from datetime import datetime
import json
import logging
import re
import traceback
from typing import AsyncGenerator, Literal
import uuid
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from deeptutor.co_writer.edit_agent import (
EditAgent,
append_history,
load_history,
print_stats,
tool_calls_dir,
)
from deeptutor.co_writer.storage import (
CoWriterDocument,
CoWriterDocumentSummary,
get_co_writer_storage,
)
from deeptutor.core.stream_bus import StreamBus
from deeptutor.services.config import PROJECT_ROOT, load_config_with_main
from deeptutor.services.llm import clean_thinking_tags
from deeptutor.services.settings.interface_settings import get_ui_language
router = APIRouter()
# Initialize logger with config
config = load_config_with_main("main.yaml", PROJECT_ROOT)
log_dir = config.get("paths", {}).get("user_log_dir") or config.get("logging", {}).get("log_dir")
logger = logging.getLogger(__name__)
_edit_agent: EditAgent | None = None
def _current_language() -> str:
# Prefer UI settings, fall back to main.yaml system.language
return get_ui_language(default=config.get("system", {}).get("language", "en"))
def get_edit_agent() -> EditAgent:
"""
Get the singleton EditAgent instance with refreshed configuration.
Uses a singleton pattern with refresh_config() to ensure:
1. Efficient reuse of the agent instance
2. Latest LLM configuration from Settings is always used
"""
global _edit_agent
lang = _current_language()
if _edit_agent is None or getattr(_edit_agent, "language", None) != lang:
_edit_agent = EditAgent(language=lang)
# Refresh config to pick up any changes from Settings
_edit_agent.refresh_config()
return _edit_agent
# Generous ceilings — they exist to stop runaway payloads (OOM / surprise
# LLM bills), not to constrain normal documents.
_MAX_DOC_CHARS = 600_000
_MAX_SELECTION_CHARS = 120_000
_MAX_INSTRUCTION_CHARS = 10_000
class EditRequest(BaseModel):
text: str = Field(max_length=_MAX_DOC_CHARS)
instruction: str = Field(max_length=_MAX_INSTRUCTION_CHARS)
action: Literal["rewrite", "shorten", "expand"] = "rewrite"
source: Literal["rag", "web"] | None = None
kb_name: str | None = None
class EditResponse(BaseModel):
edited_text: str
operation_id: str
class ReactEditRequest(BaseModel):
selected_text: str = Field(max_length=_MAX_SELECTION_CHARS)
instruction: str = Field(default="", max_length=_MAX_INSTRUCTION_CHARS)
mode: Literal["rewrite", "shorten", "expand", "none"] = "rewrite"
tools: list[Literal["rag", "web"]] = []
kb_name: str | None = None
class ReactEditResponse(BaseModel):
edited_text: str
operation_id: str
tools_used: list[str] = []
class AutoMarkRequest(BaseModel):
text: str = Field(max_length=_MAX_DOC_CHARS)
class AutoMarkResponse(BaseModel):
marked_text: str
operation_id: str
def _default_mode_instruction(mode: str, language: str) -> str:
zh = language.startswith("zh")
defaults = {
"rewrite": "润色这段 markdown,保持原意、结构和语气自然。",
"shorten": "压缩这段 markdown,让表达更精炼,同时保留关键信息。",
"expand": "扩展这段 markdown,补充必要细节,同时保持原有风格。",
"none": "根据用户要求编辑这段 markdown。",
}
if zh:
return defaults.get(mode, defaults["none"])
defaults_en = {
"rewrite": "Rewrite this markdown snippet while preserving its meaning, structure, and tone.",
"shorten": "Shorten this markdown snippet while preserving the key information.",
"expand": "Expand this markdown snippet with helpful detail while keeping the original style.",
"none": "Edit this markdown snippet according to the user's request.",
}
return defaults_en.get(mode, defaults_en["none"])
def _build_react_edit_prompt(
*,
selected_text: str,
instruction: str,
mode: str,
language: str,
context: str = "",
) -> str:
user_instruction = instruction.strip() or _default_mode_instruction(mode, language)
if language.startswith("zh"):
context_block = f"参考资料(按需取用,不必全部使用):\n{context}\n\n" if context else ""
return (
"你正在编辑一段从 Markdown 编辑器里选中的文本。\n\n"
f"编辑模式: {mode}\n"
f"用户要求: {user_instruction}\n\n"
f"{context_block}"
"待编辑的选中文本:\n"
"```markdown\n"
f"{selected_text}\n"
"```\n\n"
"要求:\n"
"1. 只输出编辑后的那段 Markdown 文本,供编辑器直接替换。\n"
"2. 不要输出解释、标题、前后缀、代码围栏。\n"
"3. 保持 Markdown 语法合法。\n"
"4. 如果给了参考资料,把相关事实自然融入结果,不要提工具或资料来源。\n"
)
context_block = (
f"Reference material (use what is relevant, ignore the rest):\n{context}\n\n"
if context
else ""
)
return (
"You are editing a text selection from a Markdown editor.\n\n"
f"Edit mode: {mode}\n"
f"User request: {user_instruction}\n\n"
f"{context_block}"
"Selected text to edit:\n"
"```markdown\n"
f"{selected_text}\n"
"```\n\n"
"Requirements:\n"
"1. Output only the edited Markdown snippet for direct replacement.\n"
"2. Do not include explanations, headings, prefixes, suffixes, or code fences.\n"
"3. Keep the Markdown valid.\n"
"4. If reference material is given, weave the relevant facts in naturally "
"without mentioning tools or sources.\n"
)
def _strip_markdown_fence(text: str) -> str:
cleaned = text.strip()
if cleaned.startswith("```") and cleaned.endswith("```"):
lines = cleaned.splitlines()
if len(lines) >= 3:
return "\n".join(lines[1:-1]).strip()
return cleaned
def _clean_react_edit_output(text: str, *, binding: str | None, model: str | None) -> str:
return _strip_markdown_fence(clean_thinking_tags(text, binding, model))
def _normalize_react_edit_tools(tools: list[str] | None) -> list[str]:
allowed = {"rag", "web"}
result: list[str] = []
for tool in tools or []:
name = str(tool or "").strip()
if name in allowed and name not in result:
result.append(name)
return result
def _prepare_react_edit_request(
request: ReactEditRequest, language: str
) -> tuple[str, str, list[str]]:
tools = _normalize_react_edit_tools(request.tools)
instruction = request.instruction.strip()
if request.mode == "none" and not instruction:
detail = (
"请输入编辑要求,或选择 shorten / expand / rewrite 模式。"
if language.startswith("zh")
else "Provide an edit instruction, or choose shorten / expand / rewrite mode."
)
raise HTTPException(status_code=400, detail=detail)
selected_text = request.selected_text.strip("\n")
if not selected_text.strip():
detail = (
"请先选中一段文本。"
if language.startswith("zh")
else "Please select a text passage first."
)
raise HTTPException(status_code=400, detail=detail)
return selected_text, instruction, tools
_TRACE_PREVIEW_CHARS = 1200
def _trace_preview(text: str) -> str:
cleaned = text.strip()
if len(cleaned) <= _TRACE_PREVIEW_CHARS:
return cleaned
return cleaned[:_TRACE_PREVIEW_CHARS].rstrip() + "…"
async def _run_react_edit(
request: ReactEditRequest,
*,
language: str,
stream: StreamBus | None = None,
) -> dict[str, object]:
selected_text, instruction, tools = _prepare_react_edit_request(request, language)
operation_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
agent = get_edit_agent()
# Optional reference retrieval before the edit. Each tool degrades to a
# plain edit on failure — retrieval must never block the user's edit.
query = instruction or selected_text[:400]
context_blocks: list[str] = []
tools_used: list[str] = []
for tool in tools:
kb_name = request.kb_name if tool == "rag" else None
if tool == "rag" and not kb_name:
continue
if stream is not None:
await stream.tool_call(
tool,
{"query": query, **({"kb_name": kb_name} if kb_name else {})},
source="co_writer_react_edit",
stage="exploring",
)
context, _file = await agent.gather_context(
source=tool,
query=query,
kb_name=kb_name,
operation_id=operation_id,
)
if stream is not None:
await stream.tool_result(
tool,
_trace_preview(context) if context else "(no result)",
source="co_writer_react_edit",
stage="exploring",
)
if context:
context_blocks.append(context)
tools_used.append(tool)
system_prompt = (
"You are an expert markdown editor."
if not language.startswith("zh")
else "你是一个严格的 Markdown 编辑助手。"
)
prompt = _build_react_edit_prompt(
selected_text=selected_text,
instruction=instruction,
mode=request.mode,
language=language,
context="\n\n".join(context_blocks),
)
response_chunks: list[str] = []
async def _consume() -> None:
async for chunk in agent.stream_llm(
user_prompt=prompt,
system_prompt=system_prompt,
stage=f"react_edit_{request.mode}",
):
if not chunk:
continue
response_chunks.append(chunk)
if stream is not None:
await stream.content(
chunk,
source="co_writer_react_edit",
stage="responding",
)
if stream is not None:
async with stream.stage("responding", source="co_writer_react_edit"):
await _consume()
else:
await _consume()
edited_text = _clean_react_edit_output(
"".join(response_chunks),
binding=agent.binding,
model=agent.get_model(),
)
append_history(
{
"id": operation_id,
"timestamp": datetime.now().isoformat(),
"action": "react_edit",
"mode": request.mode,
"tools": tools_used,
"kb_name": request.kb_name,
"input": {
"selected_text": request.selected_text,
"instruction": instruction,
},
"output": {"edited_text": edited_text},
"model": agent.get_model(),
}
)
print_stats()
result = {
"edited_text": edited_text,
"operation_id": operation_id,
"tools_used": tools_used,
}
if stream is not None:
await stream.result(result, source="co_writer_react_edit")
return result
async def _stream_react_edit(request: ReactEditRequest) -> AsyncGenerator[str, None]:
language = _current_language()
bus = StreamBus()
error_holder: dict[str, str] = {}
result_holder: dict[str, object] | None = None
async def _run() -> None:
nonlocal result_holder
try:
result_holder = await _run_react_edit(request, language=language, stream=bus)
except HTTPException as exc:
error_holder["detail"] = str(exc.detail)
except Exception as exc:
error_holder["detail"] = str(exc)
finally:
await bus.close()
task = asyncio.create_task(_run())
try:
async for event in bus.subscribe():
yield f"event: stream\ndata: {json.dumps(event.to_dict(), default=str)}\n\n"
await task
if error_holder:
yield f"event: error\ndata: {json.dumps(error_holder, default=str)}\n\n"
else:
yield f"event: result\ndata: {json.dumps(result_holder or {}, default=str)}\n\n"
finally:
if not task.done():
task.cancel()
@router.post("/edit", response_model=EditResponse)
async def edit_text(request: EditRequest):
try:
# Get agent with refreshed LLM configuration from Settings
agent = get_edit_agent()
result = await agent.process(
text=request.text,
instruction=request.instruction,
action=request.action,
source=request.source,
kb_name=request.kb_name,
)
# Print token stats
print_stats()
return result
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/edit_react", response_model=ReactEditResponse)
async def edit_text_react(request: ReactEditRequest):
try:
return await _run_react_edit(request, language=_current_language())
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/edit_react/stream")
async def edit_text_react_stream(request: ReactEditRequest):
try:
_prepare_react_edit_request(request, _current_language())
except HTTPException:
raise
return StreamingResponse(
_stream_react_edit(request),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.post("/automark", response_model=AutoMarkResponse)
async def auto_mark_text(request: AutoMarkRequest):
"""AI auto-mark text"""
try:
# Get agent with refreshed LLM configuration from Settings
agent = get_edit_agent()
result = await agent.auto_mark(text=request.text)
# Print token stats
print_stats()
return result
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/history")
async def get_history():
"""Get all operation history"""
try:
history = load_history()
return {"history": history, "total": len(history)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/history/{operation_id}")
async def get_operation(operation_id: str):
"""Get single operation details"""
try:
history = load_history()
for op in history:
if op.get("id") == operation_id:
return op
raise HTTPException(status_code=404, detail="Operation not found")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/tool_calls/{operation_id}")
async def get_tool_call(operation_id: str):
"""Get tool call details"""
try:
# Find matching file
for filepath in tool_calls_dir().glob(f"{operation_id}_*.json"):
with open(filepath, encoding="utf-8") as f:
return json.load(f)
raise HTTPException(status_code=404, detail="Tool call not found")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ─────────────────────────────────────────────────────────────────────────────
# Document CRUD (multi-project Co-Writer)
# ─────────────────────────────────────────────────────────────────────────────
# Storage builds paths as `documents/doc_{doc_id}`; an unvalidated id like
# "a/../../x" would escape the documents root (and DELETE runs rmtree).
_DOC_ID_RE = re.compile(r"^[0-9a-f]{8,32}$")
def _validate_doc_id(doc_id: str) -> str:
if not _DOC_ID_RE.fullmatch(doc_id):
raise HTTPException(status_code=404, detail="Document not found")
return doc_id
class CreateDocumentRequest(BaseModel):
title: str | None = None
content: str = ""
class UpdateDocumentRequest(BaseModel):
title: str | None = None
content: str | None = None
class DocumentResponse(BaseModel):
id: str
title: str
content: str
created_at: float
updated_at: float
@classmethod
def from_model(cls, doc: CoWriterDocument) -> "DocumentResponse":
return cls(
id=doc.id,
title=doc.title,
content=doc.content,
created_at=doc.created_at,
updated_at=doc.updated_at,
)
class DocumentSummaryResponse(BaseModel):
id: str
title: str
created_at: float
updated_at: float
preview: str = ""
@classmethod
def from_summary(cls, summary: CoWriterDocumentSummary) -> "DocumentSummaryResponse":
return cls(
id=summary.id,
title=summary.title,
created_at=summary.created_at,
updated_at=summary.updated_at,
preview=summary.preview,
)
@router.get("/documents")
async def list_documents() -> dict[str, list[DocumentSummaryResponse]]:
"""List all Co-Writer documents (summary view, sorted by recency)."""
try:
storage = get_co_writer_storage()
summaries = storage.list_documents()
return {"documents": [DocumentSummaryResponse.from_summary(s) for s in summaries]}
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/documents", response_model=DocumentResponse)
async def create_document(request: CreateDocumentRequest) -> DocumentResponse:
"""Create a new Co-Writer document."""
try:
storage = get_co_writer_storage()
document = storage.create_document(title=request.title, content=request.content)
return DocumentResponse.from_model(document)
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/documents/{doc_id}", response_model=DocumentResponse)
async def get_document(doc_id: str) -> DocumentResponse:
"""Get a single Co-Writer document by id."""
try:
storage = get_co_writer_storage()
document = storage.load_document(_validate_doc_id(doc_id))
if document is None:
raise HTTPException(status_code=404, detail="Document not found")
return DocumentResponse.from_model(document)
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.put("/documents/{doc_id}", response_model=DocumentResponse)
async def update_document(doc_id: str, request: UpdateDocumentRequest) -> DocumentResponse:
"""Update a Co-Writer document (title and/or content)."""
try:
storage = get_co_writer_storage()
document = storage.update_document(
_validate_doc_id(doc_id), title=request.title, content=request.content
)
if document is None:
raise HTTPException(status_code=404, detail="Document not found")
return DocumentResponse.from_model(document)
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/documents/{doc_id}")
async def delete_document(doc_id: str) -> dict[str, bool]:
"""Delete a Co-Writer document."""
try:
storage = get_co_writer_storage()
_validate_doc_id(doc_id)
if not storage.doc_exists(doc_id):
raise HTTPException(status_code=404, detail="Document not found")
success = storage.delete_document(doc_id)
return {"deleted": success}
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))