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
374 lines
13 KiB
Python
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")
|