feat: land page aggregate and phase7 document ai mainline
- 收口 page aggregate 读取、本地状态与命令客户端\n- 接入 phase7 document ai sidecar 与前端编排入口\n- 更新 architecture 与 design 状态迁移
This commit is contained in:
@@ -22,6 +22,9 @@ class Settings(BaseSettings):
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
frontend_url: str = "http://localhost:3000"
|
||||
openai_api_key: str = ""
|
||||
mnote_ai_default_model: str = ""
|
||||
mnote_ai_orchestrator_api_key: str = ""
|
||||
openai_agents_session_db_path: str = ""
|
||||
lightrag_db_url: str = ""
|
||||
lightrag_collection: str = "wolai-docs"
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import chat, health, luckysheet_ws, tasks
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
api_router.include_router(tasks.router, tags=["tasks"])
|
||||
api_router.include_router(chat.router, tags=["chat"])
|
||||
from . import ai_agent, chat, health, luckysheet_ws, tasks
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
api_router.include_router(tasks.router, tags=["tasks"])
|
||||
api_router.include_router(chat.router, tags=["chat"])
|
||||
api_router.include_router(ai_agent.router, tags=["ai-agent"])
|
||||
|
||||
root_router = APIRouter()
|
||||
root_router.include_router(health.router, tags=["health"])
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from app.config import settings
|
||||
from app.services.ai_document_agent import (
|
||||
DocumentAiRunRequest,
|
||||
build_document_ai_config_payload,
|
||||
has_configured_openai_api_key,
|
||||
run_document_agent_stream,
|
||||
sdk_available,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/ai-agent")
|
||||
|
||||
|
||||
def verify_internal_key(
|
||||
x_mnote_ai_key: Annotated[str | None, Header()] = None,
|
||||
) -> None:
|
||||
expected = settings.mnote_ai_orchestrator_api_key.strip()
|
||||
if not expected:
|
||||
return
|
||||
if x_mnote_ai_key == expected:
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid orchestrator key",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"bridge": "openai_agents_python",
|
||||
"sdkAvailable": sdk_available(),
|
||||
"openaiConfigured": has_configured_openai_api_key(),
|
||||
"bridgeRuntimeMode": "local_runtime",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/document/run")
|
||||
async def run_document(
|
||||
request: Request,
|
||||
payload: DocumentAiRunRequest,
|
||||
_: None = Depends(verify_internal_key),
|
||||
) -> StreamingResponse:
|
||||
stream = run_document_agent_stream(
|
||||
payload,
|
||||
source_headers={key.lower(): value for key, value in request.headers.items()},
|
||||
)
|
||||
return StreamingResponse(stream, media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.get("/document/config")
|
||||
async def get_document_config(
|
||||
_: None = Depends(verify_internal_key),
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(build_document_ai_config_payload())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,8 @@ redis==5.1.1
|
||||
httpx==0.27.2
|
||||
python-dotenv==1.0.1
|
||||
pgvector==0.3.5
|
||||
openai==1.59.5
|
||||
python-multipart==0.0.17
|
||||
pydantic-settings==2.6.1
|
||||
typing_extensions==4.12.2
|
||||
openai==2.11.0
|
||||
openai-agents==0.7.0
|
||||
python-multipart==0.0.17
|
||||
pydantic-settings==2.6.1
|
||||
typing_extensions==4.15.0
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import contextlib
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.services.ai_document_agent import (
|
||||
DocumentAiEmitter,
|
||||
DocumentAiContextPayload,
|
||||
DocumentAiRunContext,
|
||||
DocumentAiMessage,
|
||||
DocumentAiRunRequest,
|
||||
LocalBridgeRuntime,
|
||||
build_agent_usage_from_response_usage,
|
||||
build_document_ai_config_payload,
|
||||
build_document_model_provider,
|
||||
build_document_agent_instructions,
|
||||
build_forward_headers,
|
||||
run_document_agent_stream,
|
||||
resolve_document_agent_request_model,
|
||||
resolve_document_agent_model,
|
||||
)
|
||||
|
||||
|
||||
class AiDocumentAgentTests(unittest.TestCase):
|
||||
def test_build_document_agent_instructions_includes_core_context(self):
|
||||
request = DocumentAiRunRequest(
|
||||
userId="user-1",
|
||||
messages=[DocumentAiMessage(role="user", content="帮我改写第一段")],
|
||||
context=DocumentAiContextPayload(
|
||||
documentId="doc-1",
|
||||
documentBlocks=[
|
||||
{
|
||||
"id": "block-1",
|
||||
"type": "paragraph",
|
||||
"content": [{"text": "第一段正文"}],
|
||||
"children": [],
|
||||
}
|
||||
],
|
||||
pageOptions={"wideLayout": True},
|
||||
outline={"items": ["第一段"]},
|
||||
),
|
||||
)
|
||||
|
||||
instructions = build_document_agent_instructions(request)
|
||||
|
||||
self.assertIn("documentId=doc-1", instructions)
|
||||
self.assertIn("documentBlockSummaries=", instructions)
|
||||
self.assertIn("第一段正文", instructions)
|
||||
self.assertIn("pageOptions=", instructions)
|
||||
self.assertIn("outline=", instructions)
|
||||
|
||||
def test_build_document_agent_instructions_includes_profile_policy(self):
|
||||
request = DocumentAiRunRequest(
|
||||
userId="user-1",
|
||||
profileId="page_writer_ai_first",
|
||||
messages=[DocumentAiMessage(role="user", content="帮我补一段总结")],
|
||||
context=DocumentAiContextPayload(
|
||||
documentId="doc-1",
|
||||
documentBlocks=[],
|
||||
),
|
||||
)
|
||||
|
||||
instructions = build_document_agent_instructions(request)
|
||||
|
||||
self.assertIn("AI 优先直接进入主编辑区起草", instructions)
|
||||
self.assertIn("documentId=doc-1", instructions)
|
||||
|
||||
def test_build_document_agent_instructions_includes_title_write_hard_rules(self):
|
||||
request = DocumentAiRunRequest(
|
||||
userId="user-1",
|
||||
messages=[DocumentAiMessage(role="user", content="请把当前页面标题改成“新标题”。")],
|
||||
context=DocumentAiContextPayload(
|
||||
documentId="doc-1",
|
||||
documentBlocks=[],
|
||||
),
|
||||
)
|
||||
|
||||
instructions = build_document_agent_instructions(request)
|
||||
|
||||
self.assertIn("你必须调用一次 slash_run", instructions)
|
||||
self.assertIn("/rename doc-1 <新标题>", instructions)
|
||||
|
||||
def test_build_forward_headers_only_keeps_allowed_headers(self):
|
||||
forwarded = build_forward_headers(
|
||||
{
|
||||
"authorization": "Bearer token",
|
||||
"cookie": "a=1",
|
||||
"x-request-id": "req-1",
|
||||
"x-trace-id": "trace-1",
|
||||
"x-mnote-source-channel": "next_route",
|
||||
"user-agent": "Vitest",
|
||||
"x-ignore-me": "nope",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(forwarded["authorization"], "Bearer token")
|
||||
self.assertEqual(forwarded["cookie"], "a=1")
|
||||
self.assertEqual(forwarded["x-request-id"], "req-1")
|
||||
self.assertNotIn("x-ignore-me", forwarded)
|
||||
|
||||
def test_resolve_document_agent_model_uses_prefixed_request_model_first(self):
|
||||
resolved = resolve_document_agent_model(
|
||||
request_model="gh/gpt-4.1",
|
||||
configured_default_model="ju/gpt-5.4",
|
||||
env={},
|
||||
available_models_loader=lambda _base_url, _api_key: [],
|
||||
)
|
||||
|
||||
self.assertEqual(resolved, "gh/gpt-4.1")
|
||||
|
||||
def test_resolve_document_agent_request_model_prefers_model_key_semantic_name(self):
|
||||
resolved = resolve_document_agent_request_model(
|
||||
request_model=None,
|
||||
request_model_key="gpt-5.3-codex",
|
||||
configured_default_model="ju/gpt-5.4",
|
||||
env={
|
||||
"OPENAI_BASE_URL": "http://127.0.0.1:20128/v1",
|
||||
"OPENAI_API_KEY": "sk-test",
|
||||
},
|
||||
available_models_loader=lambda _base_url, _api_key: [
|
||||
"slow",
|
||||
"fast",
|
||||
"codex",
|
||||
"ju/gpt-5.4",
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(resolved, "gpt-5.3-codex")
|
||||
|
||||
def test_resolve_document_agent_model_prefers_responses_provider_for_bare_default(self):
|
||||
resolved = resolve_document_agent_model(
|
||||
request_model=None,
|
||||
configured_default_model="",
|
||||
env={
|
||||
"CLAW_DEFAULT_MODEL": "gpt-5.4",
|
||||
"OPENAI_BASE_URL": "http://127.0.0.1:20128/v1",
|
||||
"OPENAI_API_KEY": "sk-test",
|
||||
},
|
||||
available_models_loader=lambda _base_url, _api_key: [
|
||||
"gh/gpt-5.4",
|
||||
"ju/gpt-5.4",
|
||||
"gmn/gpt-5.4",
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(resolved, "ju/gpt-5.4")
|
||||
|
||||
def test_resolve_document_agent_model_requires_explicit_prefix_when_bare_model_still_ambiguous(self):
|
||||
with self.assertRaisesRegex(RuntimeError, "provider/model"):
|
||||
resolve_document_agent_model(
|
||||
request_model=None,
|
||||
configured_default_model="",
|
||||
env={
|
||||
"CLAW_DEFAULT_MODEL": "gpt-5.4",
|
||||
"OPENAI_BASE_URL": "http://127.0.0.1:20128/v1",
|
||||
"OPENAI_API_KEY": "sk-test",
|
||||
},
|
||||
available_models_loader=lambda _base_url, _api_key: [
|
||||
"gh/gpt-5.4",
|
||||
"gmn/gpt-5.4",
|
||||
],
|
||||
)
|
||||
|
||||
def test_resolve_document_agent_model_fails_when_no_model_is_configured(self):
|
||||
with self.assertRaisesRegex(RuntimeError, "未配置文档页 AI model"):
|
||||
resolve_document_agent_model(
|
||||
request_model=None,
|
||||
configured_default_model="",
|
||||
env={},
|
||||
available_models_loader=lambda _base_url, _api_key: [],
|
||||
)
|
||||
|
||||
def test_build_document_model_provider_supports_gateway_prefixed_models(self):
|
||||
provider = build_document_model_provider(
|
||||
request_model="ju/gpt-5.4",
|
||||
configured_default_model="",
|
||||
env={
|
||||
"OPENAI_BASE_URL": "http://127.0.0.1:20128/v1",
|
||||
"OPENAI_API_KEY": "sk-test",
|
||||
},
|
||||
available_models_loader=lambda _base_url, _api_key: [
|
||||
"ju/gpt-5.4",
|
||||
"gh/gpt-4.1",
|
||||
],
|
||||
)
|
||||
|
||||
ju_model = provider.get_model("ju/gpt-5.4")
|
||||
gh_model = provider.get_model("gh/gpt-4.1")
|
||||
|
||||
self.assertEqual(getattr(ju_model, "model", None), "ju/gpt-5.4")
|
||||
self.assertEqual(getattr(gh_model, "model", None), "gh/gpt-4.1")
|
||||
|
||||
def test_build_agent_usage_from_response_usage_falls_back_to_input_plus_output(self):
|
||||
class FakeUsage:
|
||||
input_tokens = 12
|
||||
output_tokens = 5
|
||||
total_tokens = None
|
||||
input_tokens_details = None
|
||||
output_tokens_details = None
|
||||
|
||||
usage = build_agent_usage_from_response_usage(FakeUsage())
|
||||
|
||||
self.assertEqual(usage.input_tokens, 12)
|
||||
self.assertEqual(usage.output_tokens, 5)
|
||||
self.assertEqual(usage.total_tokens, 17)
|
||||
|
||||
def test_build_agent_usage_from_response_usage_ignores_invalid_total_tokens(self):
|
||||
class FakeUsage:
|
||||
input_tokens = 8
|
||||
output_tokens = 3
|
||||
total_tokens = "not-a-number"
|
||||
input_tokens_details = None
|
||||
output_tokens_details = None
|
||||
|
||||
usage = build_agent_usage_from_response_usage(FakeUsage())
|
||||
|
||||
self.assertEqual(usage.input_tokens, 8)
|
||||
self.assertEqual(usage.output_tokens, 3)
|
||||
self.assertEqual(usage.total_tokens, 11)
|
||||
|
||||
def test_build_document_ai_config_payload_exposes_model_keys_profiles_and_tools(self):
|
||||
payload = build_document_ai_config_payload(
|
||||
env={
|
||||
"OPENAI_BASE_URL": "http://127.0.0.1:20128/v1",
|
||||
"CLAW_DEFAULT_MODEL": "gpt-5.4",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(payload["provider"], "online")
|
||||
self.assertEqual(payload["baseUrl"], "http://localhost:20128/v1")
|
||||
self.assertEqual(payload["defaultModelKey"], "gpt-5.4")
|
||||
self.assertEqual(payload["defaultProfileId"], "page_writer_ai_first")
|
||||
self.assertTrue(payload["sessionEnabled"])
|
||||
self.assertTrue(any(item["key"] == "gpt-5.4" for item in payload["models"]))
|
||||
self.assertTrue(
|
||||
any(
|
||||
item["key"] == "gpt-5.4"
|
||||
and item["resolvedCombo"] == "slow"
|
||||
and item["resolvedRuntimeModel"] == "gpt-5.4"
|
||||
for item in payload["models"]
|
||||
)
|
||||
)
|
||||
self.assertTrue(any(item["id"] == "page_writer_ai_first" for item in payload["profiles"]))
|
||||
self.assertTrue(any(item["name"] == "doc_get" for item in payload["tools"]))
|
||||
|
||||
|
||||
class AiDocumentAgentBridgeRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_local_bridge_runtime_execute_tool_uses_local_runtime_result_mode(self):
|
||||
bridge = LocalBridgeRuntime()
|
||||
ctx = DocumentAiRunContext(
|
||||
user_id="user-1",
|
||||
request_id="req-1",
|
||||
trace_id="trace-1",
|
||||
session_id=None,
|
||||
workspace_id="ws-1",
|
||||
document_id="doc-1",
|
||||
document_blocks=[],
|
||||
node=None,
|
||||
subtree=None,
|
||||
outline=None,
|
||||
evidence=None,
|
||||
page_options=None,
|
||||
editor_runtime_page_options=None,
|
||||
forward_headers={},
|
||||
emitter=DocumentAiEmitter(),
|
||||
bridge=bridge,
|
||||
)
|
||||
|
||||
mock_run_bridge_runtime = AsyncMock(
|
||||
return_value={
|
||||
"ok": True,
|
||||
"result": {
|
||||
"ok": True,
|
||||
"parsed": {
|
||||
"command": "rename_doc",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
with patch(
|
||||
"app.services.ai_document_agent.run_bridge_runtime",
|
||||
new=mock_run_bridge_runtime,
|
||||
):
|
||||
result = await bridge.execute_tool(
|
||||
tool="slash_run",
|
||||
invocation_kind="command",
|
||||
context=ctx,
|
||||
args_json={"text": "/rename doc-1 新标题"},
|
||||
data={"source": "ai-orchestrator"},
|
||||
target={"pageId": "doc-1"},
|
||||
)
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["parsed"]["command"], "rename_doc")
|
||||
payload = mock_run_bridge_runtime.await_args.args[0]
|
||||
self.assertEqual(payload["kind"], "tool")
|
||||
self.assertEqual(payload["tool"]["tool"], "slash_run")
|
||||
self.assertEqual(payload["tool"]["kind"], "command")
|
||||
self.assertEqual(payload["tool"]["mode"], "result")
|
||||
self.assertEqual(payload["tool"]["target"]["pageId"], "doc-1")
|
||||
self.assertEqual(payload["context"]["source"]["channel"], "ai_orchestrator")
|
||||
self.assertEqual(payload["context"]["source"]["client"], "wolai-backend")
|
||||
|
||||
async def test_run_document_agent_stream_does_not_require_legacy_mnote_web_config(self):
|
||||
request = DocumentAiRunRequest(
|
||||
userId="user-1",
|
||||
maxSteps=1,
|
||||
messages=[DocumentAiMessage(role="user", content="请总结当前页面")],
|
||||
context=DocumentAiContextPayload(
|
||||
documentId="doc-1",
|
||||
documentBlocks=[],
|
||||
),
|
||||
)
|
||||
|
||||
class FakeRunner:
|
||||
@staticmethod
|
||||
async def run(*_args, **_kwargs):
|
||||
return SimpleNamespace(final_output="已完成")
|
||||
|
||||
with (
|
||||
patch("app.services.ai_document_agent.sdk_available", return_value=True),
|
||||
patch(
|
||||
"app.services.ai_document_agent.has_configured_openai_api_key",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"app.services.ai_document_agent.build_document_agent",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"app.services.ai_document_agent.build_document_model_provider",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"app.services.ai_document_agent.build_session",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"app.services.ai_document_agent.RunConfig",
|
||||
side_effect=lambda **kwargs: kwargs,
|
||||
),
|
||||
patch(
|
||||
"app.services.ai_document_agent.Runner",
|
||||
new=FakeRunner,
|
||||
),
|
||||
patch(
|
||||
"app.services.ai_document_agent.trace",
|
||||
new=lambda *_args, **_kwargs: contextlib.nullcontext(),
|
||||
),
|
||||
):
|
||||
chunks: list[str] = []
|
||||
async for chunk in run_document_agent_stream(request, source_headers={}):
|
||||
chunks.append(chunk)
|
||||
|
||||
text = "".join(chunks)
|
||||
self.assertIn("event: assistant_message", text)
|
||||
self.assertIn("event: completion", text)
|
||||
self.assertIn("已完成", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user