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

374 lines
13 KiB
Python

"""
EditAgent - Co-writer editing agent.
Inherits from unified BaseAgent.
"""
import asyncio
from datetime import datetime
import json
from typing import Any, Literal
import uuid
from deeptutor.agents.base_agent import BaseAgent
from deeptutor.co_writer.storage import _atomic_write_json
from deeptutor.runtime.registry.tool_registry import get_tool_registry
from deeptutor.services.llm import clean_thinking_tags
from deeptutor.services.path_service import get_path_service
from deeptutor.tools.rag_tool import rag_search
from deeptutor.tools.web_search import web_search
# Resolved per-call so a per-user PathService (set after auth) routes
# co-writer history/tool-call files under the caller's own workspace.
def _user_dir():
return get_path_service().get_co_writer_dir()
def _history_file():
return get_path_service().get_co_writer_history_file()
def tool_calls_dir():
return get_path_service().get_co_writer_tool_calls_dir()
def ensure_dirs():
"""Ensure directories exist"""
_user_dir().mkdir(parents=True, exist_ok=True)
tool_calls_dir().mkdir(parents=True, exist_ok=True)
# History is a debugging/audit trail, not a primary store: cap the entry
# count and clip stored texts so a long-lived workspace can't grow the file
# (rewritten in full on every operation) without bound.
_HISTORY_MAX_ENTRIES = 200
_HISTORY_TEXT_LIMIT = 20_000
def load_history() -> list:
"""Load history"""
ensure_dirs()
history_file = _history_file()
if history_file.exists():
try:
with open(history_file, encoding="utf-8") as f:
return json.load(f)
except Exception:
return []
return []
def save_history(history: list):
"""Save history (atomic write; a crash mid-write must not corrupt it)."""
ensure_dirs()
_atomic_write_json(_history_file(), history)
def _clip_history_value(value: Any) -> Any:
if isinstance(value, str) and len(value) > _HISTORY_TEXT_LIMIT:
return value[:_HISTORY_TEXT_LIMIT] + "…[truncated]"
if isinstance(value, dict):
return {k: _clip_history_value(v) for k, v in value.items()}
return value
def append_history(record: dict) -> None:
"""Append one operation record, clipping texts and capping entries."""
history = load_history()
history.append(_clip_history_value(record))
if len(history) > _HISTORY_MAX_ENTRIES:
history = history[-_HISTORY_MAX_ENTRIES:]
save_history(history)
def save_tool_call(call_id: str, tool_type: str, data: dict[str, Any]) -> str:
"""Save tool call result, return file path"""
ensure_dirs()
filename = f"{call_id}_{tool_type}.json"
filepath = tool_calls_dir() / filename
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return str(filepath)
class EditAgent(BaseAgent):
"""Co-writer editing agent using unified BaseAgent."""
def __init__(
self,
language: str = "en",
enabled_tools: list[str] | None = None,
**kwargs: Any,
):
"""
Initialize EditAgent.
Args:
language: Language setting ('en' | 'zh'), default 'en'
Note: LLM configuration (api_key, base_url, model, etc.) is loaded
automatically from the unified config service. Use refresh_config()
to pick up configuration changes made in Settings.
"""
super().__init__(
module_name="co_writer",
agent_name="edit_agent",
language=language,
**kwargs,
)
self.enabled_tools = enabled_tools or ["rag", "web_search"]
self._tool_registry = get_tool_registry()
async def process(
self,
text: str,
instruction: str,
action: Literal["rewrite", "shorten", "expand"] = "rewrite",
source: Literal["rag", "web"] | None = None,
kb_name: str | None = None,
) -> dict[str, Any]:
"""
Process edit request
Returns:
Dict containing:
- edited_text: Edited text
- operation_id: Operation ID
"""
operation_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
context = ""
tool_call_file = None
if source:
context, tool_call_file = await self.gather_context(
source=source,
query=instruction,
kb_name=kb_name,
operation_id=operation_id,
)
if not context:
source = None
# Build prompts
system_template = self.get_prompt(
"system",
"You are an expert editor and writing assistant.\n\nAvailable reference tools:\n{available_tools}",
)
system_prompt = system_template.format(available_tools=self._build_available_tools_text())
action_verbs = {"rewrite": "Rewrite", "shorten": "Shorten", "expand": "Expand"}
action_verb = action_verbs.get(action, "Rewrite")
action_template = self.get_prompt(
"action_template",
"{action_verb} the following text based on the user's instruction.\n\nUser Instruction: {instruction}\n\n",
)
user_prompt = action_template.format(action_verb=action_verb, instruction=instruction)
if context:
context_template = self.get_prompt(
"context_template", "Reference Context ({source_label}):\n{context}\n\n"
)
user_prompt += context_template.format(
context=context,
source_label=self._get_source_label(source),
)
text_template = self.get_prompt(
"user_template",
"Target Text to Edit:\n{text}\n\nOutput only the edited text, without quotes or explanations.",
)
user_prompt += text_template.format(text=text)
# Call LLM using inherited method
self.logger.info(f"Calling LLM for {action}...")
_chunks: list[str] = []
async for _c in self.stream_llm(
user_prompt=user_prompt,
system_prompt=system_prompt,
stage=f"edit_{action}",
):
_chunks.append(_c)
response = clean_thinking_tags("".join(_chunks), self.binding, self.get_model())
# Record operation history
append_history(
{
"id": operation_id,
"timestamp": datetime.now().isoformat(),
"action": action,
"source": source,
"kb_name": kb_name,
"input": {"original_text": text, "instruction": instruction},
"output": {"edited_text": response},
"tool_call_file": tool_call_file,
"model": self.get_model(),
}
)
self.logger.info(f"Operation {operation_id} recorded successfully")
return {"edited_text": response, "operation_id": operation_id}
async def gather_context(
self,
*,
source: Literal["rag", "web"],
query: str,
kb_name: str | None = None,
operation_id: str = "",
) -> tuple[str, str | None]:
"""Fetch reference context for an edit.
Returns ``(context, tool_call_file)``; empty context means the tool
was unavailable or failed — callers degrade to a plain edit.
"""
if source == "rag":
if "rag" not in self.enabled_tools:
self.logger.warning("RAG source requested but tool is not enabled")
return "", None
if not kb_name:
self.logger.warning("RAG source selected but no kb_name provided")
return "", None
self.logger.info(f"Searching RAG in KB: {kb_name} for: {query}")
try:
search_result = await rag_search(
query=query, kb_name=kb_name, only_need_context=True
)
context = search_result.get("answer", "")
self.logger.info(f"RAG context found: {len(context)} chars")
tool_call_file = save_tool_call(
operation_id,
"rag",
{
"type": "rag",
"timestamp": datetime.now().isoformat(),
"operation_id": operation_id,
"query": query,
"kb_name": kb_name,
"mode": "naive",
"context": context,
"raw_result": search_result,
},
)
return context, tool_call_file
except Exception as e:
self.logger.error(f"RAG search failed: {e}, continuing without context")
return "", None
if "web_search" not in self.enabled_tools:
self.logger.warning("Web source requested but tool is not enabled")
return "", None
self.logger.info(f"Searching Web for: {query}")
try:
# web_search is synchronous network I/O; keep it off the
# event loop so concurrent requests aren't stalled.
search_result = await asyncio.to_thread(web_search, query)
context = search_result.get("answer", "")
self.logger.info(f"Web context found: {len(context)} chars")
tool_call_file = save_tool_call(
operation_id,
"web",
{
"type": "web_search",
"timestamp": datetime.now().isoformat(),
"operation_id": operation_id,
"query": query,
"answer": context,
"citations": search_result.get("citations", []),
"search_results": search_result.get("search_results", []),
"usage": search_result.get("usage", {}),
},
)
return context, tool_call_file
except Exception as e:
self.logger.error(f"Web search failed: {e}, continuing without context")
return "", None
async def auto_mark(self, text: str) -> dict[str, Any]:
"""
AI auto-marking feature - Add annotation tags to text
Returns:
Dict containing:
- marked_text: Text with annotations
- operation_id: Operation ID
"""
operation_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
system_prompt = self.get_prompt("auto_mark_system", "")
user_template = self.get_prompt(
"auto_mark_user_template", "Process the following text:\n{text}"
)
user_prompt = user_template.format(text=text)
self.logger.info("Calling LLM for auto-mark...")
_chunks: list[str] = []
async for _c in self.stream_llm(
user_prompt=user_prompt,
system_prompt=system_prompt,
stage="auto_mark",
):
_chunks.append(_c)
response = clean_thinking_tags("".join(_chunks), self.binding, self.get_model())
# Record operation history
append_history(
{
"id": operation_id,
"timestamp": datetime.now().isoformat(),
"action": "automark",
"source": None,
"kb_name": None,
"input": {"original_text": text, "instruction": "AI Auto Mark"},
"output": {"edited_text": response},
"tool_call_file": None,
"model": self.get_model(),
}
)
self.logger.info(f"Auto-mark operation {operation_id} recorded successfully")
return {"marked_text": response, "operation_id": operation_id}
def _build_available_tools_text(self) -> str:
tool_names = [name for name in self.enabled_tools if name in {"rag", "web_search"}]
if not tool_names:
return (
"(当前未启用外部参考工具)"
if str(self.language).lower().startswith("zh")
else "(no external reference tools enabled)"
)
return self._tool_registry.build_prompt_text(
tool_names,
format="list",
language=self.language,
)
def _get_source_label(self, source: Literal["rag", "web"] | None) -> str:
labels = {
"en": {"rag": "knowledge base", "web": "web search"},
"zh": {"rag": "知识库", "web": "网页搜索"},
}
lang = "zh" if str(self.language).lower().startswith("zh") else "en"
if source in labels[lang]:
return labels[lang][source]
return "reference" if lang == "en" else "参考资料"
# Legacy compatibility - export get_stats pointing to BaseAgent's stats
def get_stats():
"""Get shared stats tracker for co_writer module."""
return BaseAgent.get_stats("co_writer")
def reset_stats():
"""Reset shared stats for co_writer module."""
BaseAgent.reset_stats("co_writer")
def print_stats():
"""Print stats summary for co_writer module."""
BaseAgent.print_stats("co_writer")