Browse Source

feat: Implement knowledge CRUD, indexing, search, and settings services

Jax Docker 1 month ago
parent
commit
fd804d7acb
48 changed files with 2067 additions and 1390 deletions
  1. 8 1
      .claude/settings.json
  2. 3 0
      libs/core-shared/src/core_shared/__init__.py
  3. 83 0
      libs/core-shared/src/core_shared/errors.py
  4. 14 13
      services/agent-service/app/api/routes.py
  5. 24 23
      services/api-gateway/app/api/routes.py
  6. 7 7
      services/auth-service/app/api/identity_routes.py
  7. 2 1
      services/code-runner-service/app/api/routes.py
  8. 3 2
      services/event-service/app/api/routes.py
  9. 7 6
      services/human-service/app/api/routes.py
  10. 26 25
      services/knowledge-service/app/api/routes.py
  11. 108 0
      services/knowledge-service/app/application/_storage_mixin.py
  12. 1 2
      services/knowledge-service/app/application/chunking.py
  13. 247 0
      services/knowledge-service/app/application/crud_service.py
  14. 809 0
      services/knowledge-service/app/application/indexing_service.py
  15. 158 0
      services/knowledge-service/app/application/search_service.py
  16. 145 1128
      services/knowledge-service/app/application/services.py
  17. 70 0
      services/knowledge-service/app/application/settings_service.py
  18. 5 4
      services/memory-service/app/api/routes.py
  19. 19 18
      services/model-gateway-service/app/api/routes.py
  20. 3 3
      services/scheduler-service/app/api/routes.py
  21. 4 3
      services/session-service/app/api/routes.py
  22. 9 8
      services/skill-service/app/api/routes.py
  23. 18 18
      services/team-service/app/api/routes.py
  24. 15 14
      services/tool-service/app/api/routes.py
  25. 56 46
      tests/test_team_service.py
  26. 31 0
      web/src/api/errors.ts
  27. 1 1
      web/src/components/shared/LoadingSpinner.tsx
  28. 1 1
      web/src/components/shared/SearchInput.tsx
  29. 59 3
      web/src/locales/en.json
  30. 59 3
      web/src/locales/zh.json
  31. 4 3
      web/src/pages/agents/AgentListPage.tsx
  32. 2 1
      web/src/pages/agents/components/AgentRuns.tsx
  33. 5 4
      web/src/pages/agents/components/CreateAgentDialog.tsx
  34. 2 1
      web/src/pages/apps/AppsPage.tsx
  35. 1 1
      web/src/pages/apps/components/AppApiKeysPanel.tsx
  36. 2 1
      web/src/pages/apps/components/CreateAppDialog.tsx
  37. 20 24
      web/src/pages/knowledge/KnowledgePage.tsx
  38. 2 1
      web/src/pages/memories/MemoryPage.tsx
  39. 11 10
      web/src/pages/models/ModelsPage.tsx
  40. 2 1
      web/src/pages/settings/SettingsPage.tsx
  41. 5 4
      web/src/pages/skills/SkillsPage.tsx
  42. 3 2
      web/src/pages/teams/TeamsPage.tsx
  43. 3 2
      web/src/pages/teams/components/CreateTeamDialog.tsx
  44. 2 1
      web/src/pages/teams/components/TeamRuns.tsx
  45. 2 1
      web/src/pages/tools/ToolsPage.tsx
  46. 2 1
      web/src/pages/tools/components/ConnectMcpServerDialog.tsx
  47. 2 1
      web/src/pages/tools/components/CreateToolDialog.tsx
  48. 2 1
      web/src/pages/tools/components/ToolDetailSheet.tsx

+ 8 - 1
.claude/settings.json

@@ -7,7 +7,14 @@
       "Bash(python -c ' *)",
       "Bash(python -c ' *)",
       "Bash(python -c \"import ast; ast.parse\\(open\\('services/team-service/app/application/services.py'\\).read\\(\\)\\); print\\('Syntax OK'\\)\")",
       "Bash(python -c \"import ast; ast.parse\\(open\\('services/team-service/app/application/services.py'\\).read\\(\\)\\); print\\('Syntax OK'\\)\")",
       "Bash(python -c \"import ast; ast.parse\\(open\\('services/knowledge-service/app/application/services.py'\\).read\\(\\)\\); print\\('OK'\\)\")",
       "Bash(python -c \"import ast; ast.parse\\(open\\('services/knowledge-service/app/application/services.py'\\).read\\(\\)\\); print\\('OK'\\)\")",
-      "Bash(python -c \"import ast; ast.parse\\(open\\('services/team-service/app/infrastructure/agent_client.py'\\).read\\(\\)\\); print\\('Syntax OK'\\)\")"
+      "Bash(python -c \"import ast; ast.parse\\(open\\('services/team-service/app/infrastructure/agent_client.py'\\).read\\(\\)\\); print\\('Syntax OK'\\)\")",
+      "Bash(python -m pytest tests/test_team_service.py -v --timeout=30)",
+      "Bash(python -c \"import ast; ast.parse\\(open\\('services/knowledge-service/app/application/retrieval.py'\\).read\\(\\)\\); ast.parse\\(open\\('services/knowledge-service/app/application/chunking.py'\\).read\\(\\)\\); ast.parse\\(open\\('services/knowledge-service/app/application/document_parsers.py'\\).read\\(\\)\\); print\\('All OK'\\)\")",
+      "Bash(uv run *)",
+      "Bash(python -c \"from core_shared import error_detail; print\\(error_detail\\('error.api_key.not_found', id='abc'\\)\\)\")",
+      "Bash(python -m pytest tests/test_team_service.py -v)",
+      "Bash(python -c \"from core_shared import error_detail; print\\(error_detail\\('error.tool_connection.not_found', id='x'\\)\\); print\\(error_detail\\('error.knowledge.indexing_failed', message='test'\\)\\); print\\(error_detail\\('error.storage.unavailable', message='disk full'\\)\\); print\\(error_detail\\('error.code_runner.execution_failed', message='timeout'\\)\\)\")",
+      "Bash(python -c \"from core_shared import error_detail, ERROR_CODES; print\\(f'{len\\(ERROR_CODES\\)} error codes defined'\\)\")"
     ]
     ]
   }
   }
 }
 }

+ 3 - 0
libs/core-shared/src/core_shared/__init__.py

@@ -1,10 +1,13 @@
 from .config import ServiceSettings
 from .config import ServiceSettings
+from .errors import ERROR_CODES, error_detail
 from .optional_redis import try_build_redis_client
 from .optional_redis import try_build_redis_client
 from .types import JSONPrimitive, JSONValue
 from .types import JSONPrimitive, JSONValue
 
 
 __all__ = [
 __all__ = [
+    "ERROR_CODES",
     "JSONPrimitive",
     "JSONPrimitive",
     "JSONValue",
     "JSONValue",
     "ServiceSettings",
     "ServiceSettings",
+    "error_detail",
     "try_build_redis_client",
     "try_build_redis_client",
 ]
 ]

+ 83 - 0
libs/core-shared/src/core_shared/errors.py

@@ -0,0 +1,83 @@
+"""Structured error codes for all backend services.
+
+Each error code maps to an English fallback message.  Services use
+``error_detail(code, **kwargs)`` to produce a dict that FastAPI
+serialises into the ``detail`` field of an HTTPException response:
+
+    {"code": "error.api_key.not_found", "message": "API key not found: abc-123"}
+
+The frontend reads ``code`` as an i18n key and falls back to ``message``.
+"""
+
+ERROR_CODES: dict[str, str] = {
+    # ── Generic ──────────────────────────────────────────────────────
+    "error.validation": "Validation error: {message}",
+    # ── Auth ─────────────────────────────────────────────────────────
+    "error.auth.missing_header": "Missing authorization header",
+    "error.auth.invalid_header": "Invalid authorization header",
+    "error.auth.invalid_credentials": "Invalid username or password",
+    "error.auth.invalid_token": "Invalid token",
+    "error.auth.user_not_found": "User not found",
+    "error.auth.missing_token": "Missing bearer token or api key",
+    # ── API Key ──────────────────────────────────────────────────────
+    "error.api_key.invalid": "Invalid api key",
+    "error.api_key.expired": "Api key expired",
+    "error.api_key.not_found": "API key not found: {id}",
+    "error.api_key.unauthorized": "API key does not belong to this app",
+    # ── App ──────────────────────────────────────────────────────────
+    "error.app.not_found": "App not found: {id}",
+    "error.app.not_published": "App is {status}, not published",
+    "error.app.code_exists": "App code already exists: {code}",
+    # ── Session ──────────────────────────────────────────────────────
+    "error.session.not_found": "Session not found: {id}",
+    "error.session.runtime_not_configured": "Session runtime target is not configured",
+    "error.run_request.not_found": "Run request not found: {id}",
+    # ── Agent ────────────────────────────────────────────────────────
+    "error.agent.not_found": "Agent not found: {id}",
+    "error.agent_run.not_found": "Agent run not found: {id}",
+    "error.agent_run.not_found_queued": "Queued agent run not found",
+    # ── Model / Provider ─────────────────────────────────────────────
+    "error.model.not_found": "Model not found: {id}",
+    "error.provider.not_found": "Provider not found: {id}",
+    # ── Team ─────────────────────────────────────────────────────────
+    "error.team.not_found": "Team not found: {id}",
+    "error.team_config.not_found": "Team config not found: {id}",
+    "error.team_run.not_found": "Team run not found: {id}",
+    "error.team_run.not_found_queued": "Queued team run not found",
+    # ── Tool ─────────────────────────────────────────────────────────
+    "error.tool.not_found": "Tool not found: {id}",
+    "error.tool_binding.not_found": "Tool binding not found: {id}",
+    "error.tool_connection.not_found": "Tool connection not found: {id}",
+    "error.tool_credential.not_found": "Tool credential not found: {id}",
+    # ── Skill ────────────────────────────────────────────────────────
+    "error.skill.not_found": "Skill not found: {id}",
+    "error.skill_installation.not_found": "Skill installation not found: {id}",
+    "error.skill_run.not_found": "Skill run not found: {id}",
+    # ── Knowledge ────────────────────────────────────────────────────
+    "error.knowledge_base.not_found": "Knowledge base not found: {id}",
+    "error.knowledge_document.not_found": "Knowledge document not found: {id}",
+    "error.knowledge_index_job.not_found": "Knowledge index job not found for document: {id}",
+    "error.knowledge_chunk.not_found": "Knowledge chunk not found: {id}",
+    "error.knowledge.indexing_failed": "Knowledge indexing failed: {message}",
+    # ── Storage ──────────────────────────────────────────────────────
+    "error.storage.unavailable": "Object storage unavailable: {message}",
+    # ── Memory / Human / Event ───────────────────────────────────────
+    "error.memory.not_found": "Memory not found: {id}",
+    "error.human_task.not_found": "Human task not found: {id}",
+    "error.event.not_found": "Event not found: {id}",
+    "error.scheduled_job.not_found": "Scheduled job not found: {id}",
+    # ── Downstream ───────────────────────────────────────────────────
+    "error.downstream.request_failed": "{service} request failed: {error}",
+    "error.downstream.unexpected_response": "{service} returned unexpected response",
+    "error.downstream.missing_field": "Downstream response missing {field}",
+    "error.downstream.generic_failure": "Downstream request failed with status {status}",
+    # ── Code Runner ──────────────────────────────────────────────────
+    "error.code_runner.execution_failed": "Code execution failed: {message}",
+}
+
+
+def error_detail(code: str, **kwargs: str) -> dict:
+    """Return ``{"code": ..., "message": ...}`` for an HTTPException detail."""
+    template = ERROR_CODES.get(code, code)
+    message = template.format(**kwargs) if kwargs else template
+    return {"code": code, "message": message}

+ 14 - 13
services/agent-service/app/api/routes.py

@@ -1,6 +1,7 @@
 import json
 import json
 
 
 from core_domain import ServiceHealth
 from core_domain import ServiceHealth
+from core_shared import error_detail
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi.responses import StreamingResponse
 from fastapi.responses import StreamingResponse
 from sqlalchemy import text
 from sqlalchemy import text
@@ -87,7 +88,7 @@ def detail_agent(
     service: AgentApplicationService = Depends(get_agent_application_service)) -> AgentResponse:
     service: AgentApplicationService = Depends(get_agent_application_service)) -> AgentResponse:
     entity = service.get_agent(agent_id=payload.agent_id)
     entity = service.get_agent(agent_id=payload.agent_id)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"agent not found: {payload.agent_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.agent.not_found", id=payload.agent_id))
     return AgentResponse.from_entity(entity)
     return AgentResponse.from_entity(entity)
 
 
 
 
@@ -97,7 +98,7 @@ def update_agent(
     service: AgentApplicationService = Depends(get_agent_application_service)) -> AgentResponse:
     service: AgentApplicationService = Depends(get_agent_application_service)) -> AgentResponse:
     entity = service.update_agent(payload)
     entity = service.update_agent(payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"agent not found: {payload.agent_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.agent.not_found", id=payload.agent_id))
     return AgentResponse.from_entity(entity)
     return AgentResponse.from_entity(entity)
 
 
 
 
@@ -108,7 +109,7 @@ def update_agent_status(
     service: AgentApplicationService = Depends(get_agent_application_service)) -> AgentResponse:
     service: AgentApplicationService = Depends(get_agent_application_service)) -> AgentResponse:
     entity = service.update_agent_status(agent_id=agent_id, payload=payload)
     entity = service.update_agent_status(agent_id=agent_id, payload=payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"agent not found: {agent_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.agent.not_found", id=agent_id))
     return AgentResponse.from_entity(entity)
     return AgentResponse.from_entity(entity)
 
 
 
 
@@ -120,7 +121,7 @@ def update_agent_status_post(
         agent_id=payload.agent_id,
         agent_id=payload.agent_id,
         payload=AgentStatusUpdateRequest(status=payload.status))
         payload=AgentStatusUpdateRequest(status=payload.status))
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"agent not found: {payload.agent_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.agent.not_found", id=payload.agent_id))
     return AgentResponse.from_entity(entity)
     return AgentResponse.from_entity(entity)
 
 
 
 
@@ -140,7 +141,7 @@ def create_agent_config(
     try:
     try:
         entity = service.create_agent_config(payload)
         entity = service.create_agent_config(payload)
     except ValueError as exc:
     except ValueError as exc:
-        raise HTTPException(status_code=422, detail=str(exc)) from exc
+        raise HTTPException(status_code=422, detail=error_detail("error.validation", message=str(exc))) from exc
     return AgentConfigResponse.from_entity(entity)
     return AgentConfigResponse.from_entity(entity)
 
 
 
 
@@ -161,7 +162,7 @@ def create_agent_run(
     try:
     try:
         entity = service.create_agent_run(payload)
         entity = service.create_agent_run(payload)
     except ValueError as exc:
     except ValueError as exc:
-        raise HTTPException(status_code=422, detail=str(exc)) from exc
+        raise HTTPException(status_code=422, detail=error_detail("error.validation", message=str(exc))) from exc
     return AgentRunResponse.from_entity(entity)
     return AgentRunResponse.from_entity(entity)
 
 
 
 
@@ -196,7 +197,7 @@ def get_agent_run(
     service: AgentApplicationService = Depends(get_agent_application_service)) -> AgentRunResponse:
     service: AgentApplicationService = Depends(get_agent_application_service)) -> AgentRunResponse:
     entity = service.get_agent_run(payload)
     entity = service.get_agent_run(payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"agent_run not found: {payload.agent_run_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.agent_run.not_found", id=payload.agent_run_id))
     return AgentRunResponse.from_entity(entity)
     return AgentRunResponse.from_entity(entity)
 
 
 
 
@@ -233,7 +234,7 @@ def update_agent_run_status(
     service: AgentApplicationService = Depends(get_agent_application_service)) -> AgentRunResponse:
     service: AgentApplicationService = Depends(get_agent_application_service)) -> AgentRunResponse:
     entity = service.update_agent_run_status(agent_run_id=agent_run_id, payload=payload)
     entity = service.update_agent_run_status(agent_run_id=agent_run_id, payload=payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"agent_run not found: {agent_run_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.agent_run.not_found", id=agent_run_id))
     return AgentRunResponse.from_entity(entity)
     return AgentRunResponse.from_entity(entity)
 
 
 
 
@@ -251,7 +252,7 @@ def update_agent_run_status_post(
             error_code=payload.error_code,
             error_code=payload.error_code,
             error_message=payload.error_message))
             error_message=payload.error_message))
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"agent_run not found: {payload.agent_run_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.agent_run.not_found", id=payload.agent_run_id))
     return AgentRunResponse.from_entity(entity)
     return AgentRunResponse.from_entity(entity)
 
 
 
 
@@ -262,7 +263,7 @@ def execute_agent_run(
     service: AgentApplicationService = Depends(get_agent_application_service)) -> AgentRunExecuteResponse:
     service: AgentApplicationService = Depends(get_agent_application_service)) -> AgentRunExecuteResponse:
     entity = service.execute_agent_run(agent_run_id=agent_run_id, payload=payload)
     entity = service.execute_agent_run(agent_run_id=agent_run_id, payload=payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"agent_run not found: {agent_run_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.agent_run.not_found", id=agent_run_id))
 
 
     output_json = entity.output_json or {}
     output_json = entity.output_json or {}
     model_value = output_json.get("model")
     model_value = output_json.get("model")
@@ -279,7 +280,7 @@ def execute_agent_run_stream(
     payload: AgentRunExecuteRequest,
     payload: AgentRunExecuteRequest,
     service: AgentApplicationService = Depends(get_agent_application_service)) -> StreamingResponse:
     service: AgentApplicationService = Depends(get_agent_application_service)) -> StreamingResponse:
     if service.get_agent_run(AgentRunDetailRequest(agent_run_id=agent_run_id)) is None:
     if service.get_agent_run(AgentRunDetailRequest(agent_run_id=agent_run_id)) is None:
-        raise HTTPException(status_code=404, detail=f"agent_run not found: {agent_run_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.agent_run.not_found", id=agent_run_id))
 
 
     def events():
     def events():
         for item in service.execute_agent_run_stream(agent_run_id=agent_run_id, payload=payload):
         for item in service.execute_agent_run_stream(agent_run_id=agent_run_id, payload=payload):
@@ -311,7 +312,7 @@ def execute_agent_run_post(
             worker_key=payload.worker_key,
             worker_key=payload.worker_key,
             dry_run=payload.dry_run))
             dry_run=payload.dry_run))
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"agent_run not found: {payload.agent_run_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.agent_run.not_found", id=payload.agent_run_id))
 
 
     output_json = entity.output_json or {}
     output_json = entity.output_json or {}
     model_value = output_json.get("model")
     model_value = output_json.get("model")
@@ -332,7 +333,7 @@ def execute_next_worker_task(
         lease_seconds=payload.lease_seconds or settings.worker_lease_seconds,
         lease_seconds=payload.lease_seconds or settings.worker_lease_seconds,
         dry_run=payload.dry_run if payload.dry_run is not None else settings.worker_dry_run)
         dry_run=payload.dry_run if payload.dry_run is not None else settings.worker_dry_run)
     if result is None:
     if result is None:
-        raise HTTPException(status_code=404, detail="queued agent_run not found")
+        raise HTTPException(status_code=404, detail=error_detail("error.agent_run.not_found_queued"))
 
 
     entity, released_lease_count = result
     entity, released_lease_count = result
     output_json = entity.output_json or {}
     output_json = entity.output_json or {}

+ 24 - 23
services/api-gateway/app/api/routes.py

@@ -23,6 +23,7 @@ from app.domain.repositories import (
 )
 )
 from app.infrastructure.api_keys import generate_api_key, get_api_key_prefix, hash_api_key
 from app.infrastructure.api_keys import generate_api_key, get_api_key_prefix, hash_api_key
 from app.infrastructure.proxy import ProxyServiceName, ProxyTarget, ServiceProxy
 from app.infrastructure.proxy import ProxyServiceName, ProxyTarget, ServiceProxy
+from core_shared import error_detail
 from core_shared.security import build_internal_service_headers
 from core_shared.security import build_internal_service_headers
 from app.schemas.gateway import (
 from app.schemas.gateway import (
     ApiKeyCreateRequest,
     ApiKeyCreateRequest,
@@ -139,7 +140,7 @@ def update_api_key_status(
         api_key_id=api_key_id,
         api_key_id=api_key_id,
         status=payload.status)
         status=payload.status)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"api key not found: {api_key_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.api_key.not_found", id=api_key_id))
     return ApiKeyResponse.from_entity(entity)
     return ApiKeyResponse.from_entity(entity)
 
 
 
 
@@ -151,7 +152,7 @@ def update_api_key_status_post(
         api_key_id=payload.api_key_id,
         api_key_id=payload.api_key_id,
         status=payload.status)
         status=payload.status)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"api key not found: {payload.api_key_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.api_key.not_found", id=payload.api_key_id))
     return ApiKeyResponse.from_entity(entity)
     return ApiKeyResponse.from_entity(entity)
 
 
 
 
@@ -319,7 +320,7 @@ async def execute_session(
         target_id = _get_string(session, "runtime_target_id")
         target_id = _get_string(session, "runtime_target_id")
         target_config_id = _get_optional_string(session, "runtime_target_config_id")
         target_config_id = _get_optional_string(session, "runtime_target_config_id")
         if target_type not in {"agent", "team"} or not target_id:
         if target_type not in {"agent", "team"} or not target_id:
-            raise HTTPException(status_code=422, detail="session runtime target is not configured")
+            raise HTTPException(status_code=422, detail=error_detail("error.session.runtime_not_configured"))
 
 
         run_request_payload = {
         run_request_payload = {
             "target_type": target_type,
             "target_type": target_type,
@@ -514,7 +515,7 @@ async def _stream_session_execute(
         target_id = _get_string(session, "runtime_target_id")
         target_id = _get_string(session, "runtime_target_id")
         target_config_id = _get_optional_string(session, "runtime_target_config_id")
         target_config_id = _get_optional_string(session, "runtime_target_config_id")
         if target_type not in {"agent", "team"} or not target_id:
         if target_type not in {"agent", "team"} or not target_id:
-            raise HTTPException(status_code=422, detail="session runtime target is not configured")
+            raise HTTPException(status_code=422, detail=error_detail("error.session.runtime_not_configured"))
 
 
         run_request_payload = {
         run_request_payload = {
             "target_type": target_type, "target_id": target_id,
             "target_type": target_type, "target_id": target_id,
@@ -663,7 +664,7 @@ async def _stream_session_execute(
 def create_app(payload: AppCreateRequest, db: DbSession) -> AppResponse:
 def create_app(payload: AppCreateRequest, db: DbSession) -> AppResponse:
     existing = AppDefinitionRepository(db).get_by_code(code=payload.code)
     existing = AppDefinitionRepository(db).get_by_code(code=payload.code)
     if existing is not None:
     if existing is not None:
-        raise HTTPException(status_code=409, detail=f"app code already exists: {payload.code}")
+        raise HTTPException(status_code=409, detail=error_detail("error.app.code_exists", code=payload.code))
     entity = AppDefinitionRepository(db).create(
     entity = AppDefinitionRepository(db).create(
         code=payload.code,
         code=payload.code,
         name=payload.name,
         name=payload.name,
@@ -684,7 +685,7 @@ def list_apps(payload: AppListRequest, db: DbSession) -> list[AppResponse]:
 def get_app_detail(payload: AppDetailRequest, db: DbSession) -> AppResponse:
 def get_app_detail(payload: AppDetailRequest, db: DbSession) -> AppResponse:
     entity = AppDefinitionRepository(db).get_by_id(app_id=payload.app_id)
     entity = AppDefinitionRepository(db).get_by_id(app_id=payload.app_id)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"app not found: {payload.app_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.app.not_found", id=payload.app_id))
     return AppResponse.from_entity(entity)
     return AppResponse.from_entity(entity)
 
 
 
 
@@ -698,7 +699,7 @@ def update_app(payload: AppUpdateRequest, db: DbSession) -> AppResponse:
         target_id=payload.target_id,
         target_id=payload.target_id,
         settings_json=payload.settings_json)
         settings_json=payload.settings_json)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"app not found: {payload.app_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.app.not_found", id=payload.app_id))
     return AppResponse.from_entity(entity)
     return AppResponse.from_entity(entity)
 
 
 
 
@@ -706,7 +707,7 @@ def update_app(payload: AppUpdateRequest, db: DbSession) -> AppResponse:
 def update_app_status(payload: AppStatusUpdateRequest, db: DbSession) -> AppResponse:
 def update_app_status(payload: AppStatusUpdateRequest, db: DbSession) -> AppResponse:
     entity = AppDefinitionRepository(db).update_status(app_id=payload.app_id, status=payload.status)
     entity = AppDefinitionRepository(db).update_status(app_id=payload.app_id, status=payload.status)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"app not found: {payload.app_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.app.not_found", id=payload.app_id))
     return AppResponse.from_entity(entity)
     return AppResponse.from_entity(entity)
 
 
 
 
@@ -714,7 +715,7 @@ def update_app_status(payload: AppStatusUpdateRequest, db: DbSession) -> AppResp
 def create_app_api_key(app_id: str, payload: AppApiKeyCreateRequest, db: DbSession) -> AppApiKeyCreateResponse:
 def create_app_api_key(app_id: str, payload: AppApiKeyCreateRequest, db: DbSession) -> AppApiKeyCreateResponse:
     app_entity = AppDefinitionRepository(db).get_by_id(app_id=app_id)
     app_entity = AppDefinitionRepository(db).get_by_id(app_id=app_id)
     if app_entity is None:
     if app_entity is None:
-        raise HTTPException(status_code=404, detail=f"app not found: {app_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.app.not_found", id=app_id))
     api_key = generate_api_key()
     api_key = generate_api_key()
     entity = AppApiKeyRepository(db).create(
     entity = AppApiKeyRepository(db).create(
         app_id=app_id,
         app_id=app_id,
@@ -744,7 +745,7 @@ def list_app_api_keys(app_id: str, payload: AppApiKeyListRequest, db: DbSession)
 def update_app_api_key_status(app_id: str, payload: AppApiKeyStatusUpdateRequest, db: DbSession) -> AppApiKeyResponse:
 def update_app_api_key_status(app_id: str, payload: AppApiKeyStatusUpdateRequest, db: DbSession) -> AppApiKeyResponse:
     entity = AppApiKeyRepository(db).update_status(api_key_id=payload.api_key_id, status=payload.status)
     entity = AppApiKeyRepository(db).update_status(api_key_id=payload.api_key_id, status=payload.status)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"api key not found: {payload.api_key_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.api_key.not_found", id=payload.api_key_id))
     return AppApiKeyResponse.from_entity(entity)
     return AppApiKeyResponse.from_entity(entity)
 
 
 
 
@@ -770,20 +771,20 @@ def _authenticate_app_api_key(request: Request, db: Session):
     if token is None:
     if token is None:
         token = request.headers.get(settings.api_key_header_name)
         token = request.headers.get(settings.api_key_header_name)
     if not token:
     if not token:
-        raise HTTPException(status_code=401, detail="missing bearer token or api key")
+        raise HTTPException(status_code=401, detail=error_detail("error.auth.missing_token"))
 
 
     key_hash = hash_api_key(token)
     key_hash = hash_api_key(token)
     key_entity = AppApiKeyRepository(db).get_active_by_hash(key_hash=key_hash)
     key_entity = AppApiKeyRepository(db).get_active_by_hash(key_hash=key_hash)
     if key_entity is None:
     if key_entity is None:
-        raise HTTPException(status_code=401, detail="invalid api key")
+        raise HTTPException(status_code=401, detail=error_detail("error.api_key.invalid"))
     if key_entity.expires_time is not None and key_entity.expires_time <= datetime.utcnow():
     if key_entity.expires_time is not None and key_entity.expires_time <= datetime.utcnow():
-        raise HTTPException(status_code=401, detail="api key expired")
+        raise HTTPException(status_code=401, detail=error_detail("error.api_key.expired"))
 
 
     app_entity = AppDefinitionRepository(db).get_by_id(app_id=key_entity.app_id)
     app_entity = AppDefinitionRepository(db).get_by_id(app_id=key_entity.app_id)
     if app_entity is None:
     if app_entity is None:
-        raise HTTPException(status_code=401, detail="app not found")
+        raise HTTPException(status_code=401, detail=error_detail("error.app.not_found"))
     if app_entity.status != "published":
     if app_entity.status != "published":
-        raise HTTPException(status_code=403, detail=f"app is {app_entity.status}, not published")
+        raise HTTPException(status_code=403, detail=error_detail("error.app.not_published", status=app_entity.status))
 
 
     AppApiKeyRepository(db).touch_last_used_time(api_key_id=key_entity.id)
     AppApiKeyRepository(db).touch_last_used_time(api_key_id=key_entity.id)
     return key_entity, app_entity
     return key_entity, app_entity
@@ -795,7 +796,7 @@ async def openapi_chat(app_code: str, payload: OpenApiChatRequest, request: Requ
     request_id = str(uuid4())
     request_id = str(uuid4())
     key_entity, app_entity = _authenticate_app_api_key(request, db)
     key_entity, app_entity = _authenticate_app_api_key(request, db)
     if app_entity.code != app_code:
     if app_entity.code != app_code:
-        raise HTTPException(status_code=403, detail="api key does not belong to this app")
+        raise HTTPException(status_code=403, detail=error_detail("error.api_key.unauthorized"))
 
 
     targets = build_proxy_targets(ApiGatewaySettings())
     targets = build_proxy_targets(ApiGatewaySettings())
     session_target = targets["session-service"]
     session_target = targets["session-service"]
@@ -1410,12 +1411,12 @@ async def _post_json(
     try:
     try:
         response = await client.post(url, headers=headers, json=payload)
         response = await client.post(url, headers=headers, json=payload)
     except httpx.HTTPError as exc:
     except httpx.HTTPError as exc:
-        raise HTTPException(status_code=502, detail=f"{target.service_name} request failed: {exc}") from exc
+        raise HTTPException(status_code=502, detail=error_detail("error.downstream.request_failed", service=target.service_name, error=str(exc))) from exc
     if not response.is_success:
     if not response.is_success:
         raise HTTPException(status_code=response.status_code, detail=_error_detail(response))
         raise HTTPException(status_code=response.status_code, detail=_error_detail(response))
     data = response.json()
     data = response.json()
     if not isinstance(data, dict):
     if not isinstance(data, dict):
-        raise HTTPException(status_code=502, detail=f"{target.service_name} returned unexpected response")
+        raise HTTPException(status_code=502, detail=error_detail("error.downstream.unexpected_response", service=target.service_name))
     return data
     return data
 
 
 
 
@@ -1426,11 +1427,11 @@ def _target_url(target: ProxyTarget, path: str) -> str:
     return f"{target.base_url.rstrip('/')}{target.path_prefix}"
     return f"{target.base_url.rstrip('/')}{target.path_prefix}"
 
 
 
 
-def _error_detail(response: httpx.Response) -> str:
+def _error_detail(response: httpx.Response):
     try:
     try:
         payload = response.json()
         payload = response.json()
     except ValueError:
     except ValueError:
-        return response.text or f"downstream request failed with {response.status_code}"
+        return error_detail("error.downstream.generic_failure", status=str(response.status_code))
     if isinstance(payload, dict):
     if isinstance(payload, dict):
         detail = payload.get("detail")
         detail = payload.get("detail")
         if isinstance(detail, str):
         if isinstance(detail, str):
@@ -1440,13 +1441,13 @@ def _error_detail(response: httpx.Response) -> str:
             message = error.get("message")
             message = error.get("message")
             if isinstance(message, str):
             if isinstance(message, str):
                 return message
                 return message
-    return response.text or f"downstream request failed with {response.status_code}"
+    return error_detail("error.downstream.generic_failure", status=str(response.status_code))
 
 
 
 
 def _get_string(payload: dict[str, object], key: str) -> str:
 def _get_string(payload: dict[str, object], key: str) -> str:
     value = payload.get(key)
     value = payload.get(key)
     if not isinstance(value, str) or not value:
     if not isinstance(value, str) or not value:
-        raise HTTPException(status_code=502, detail=f"downstream response missing {key}")
+        raise HTTPException(status_code=502, detail=error_detail("error.downstream.missing_field", field=key))
     return value
     return value
 
 
 
 
@@ -1458,7 +1459,7 @@ def _get_optional_string(payload: dict[str, object], key: str) -> str | None:
 def _get_dict(payload: dict[str, object], key: str) -> dict[str, object]:
 def _get_dict(payload: dict[str, object], key: str) -> dict[str, object]:
     value = payload.get(key)
     value = payload.get(key)
     if not isinstance(value, dict):
     if not isinstance(value, dict):
-        raise HTTPException(status_code=502, detail=f"downstream response missing {key}")
+        raise HTTPException(status_code=502, detail=error_detail("error.downstream.missing_field", field=key))
     return value
     return value
 
 
 
 

+ 7 - 7
services/auth-service/app/api/identity_routes.py

@@ -2,7 +2,7 @@ from datetime import datetime
 from typing import Annotated, TypeVar
 from typing import Annotated, TypeVar
 
 
 from core_domain import ServiceHealth
 from core_domain import ServiceHealth
-from core_shared import try_build_redis_client
+from core_shared import error_detail, try_build_redis_client
 from fastapi import APIRouter, Depends, Header, HTTPException, Request
 from fastapi import APIRouter, Depends, Header, HTTPException, Request
 from sqlalchemy import text
 from sqlalchemy import text
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
@@ -71,10 +71,10 @@ def ok(request: Request, data: T) -> ApiResponse[T]:
 
 
 def get_bearer_token(authorization: str | None) -> str:
 def get_bearer_token(authorization: str | None) -> str:
     if not authorization:
     if not authorization:
-        raise HTTPException(status_code=401, detail="missing authorization header")
+        raise HTTPException(status_code=401, detail=error_detail("error.auth.missing_header"))
     scheme, _, token = authorization.partition(" ")
     scheme, _, token = authorization.partition(" ")
     if scheme.lower() != "bearer" or not token:
     if scheme.lower() != "bearer" or not token:
-        raise HTTPException(status_code=401, detail="invalid authorization header")
+        raise HTTPException(status_code=401, detail=error_detail("error.auth.invalid_header"))
     return token
     return token
 
 
 
 
@@ -91,7 +91,7 @@ def login(
     service: IdentityServiceDep) -> ApiResponse[LoginData]:
     service: IdentityServiceDep) -> ApiResponse[LoginData]:
     result = service.login(username=payload.username, password=payload.password)
     result = service.login(username=payload.username, password=payload.password)
     if result is None:
     if result is None:
-        raise HTTPException(status_code=401, detail="invalid username or password")
+        raise HTTPException(status_code=401, detail=error_detail("error.auth.invalid_credentials"))
     return ok(
     return ok(
         request,
         request,
         LoginData(
         LoginData(
@@ -133,10 +133,10 @@ def me(
     token = get_bearer_token(authorization)
     token = get_bearer_token(authorization)
     verified = service.verify_token(access_token=token)
     verified = service.verify_token(access_token=token)
     if not verified.active or verified.user_id is None:
     if not verified.active or verified.user_id is None:
-        raise HTTPException(status_code=401, detail=verified.reason or "invalid token")
+        raise HTTPException(status_code=401, detail=error_detail("error.auth.invalid_token"))
     user = service.user_repository.get_by_id(user_id=verified.user_id)
     user = service.user_repository.get_by_id(user_id=verified.user_id)
     if user is None:
     if user is None:
-        raise HTTPException(status_code=401, detail="user not found")
+        raise HTTPException(status_code=401, detail=error_detail("error.auth.user_not_found"))
 
 
     assignments = service.assignment_repository.list_by_user(user_id=user.id)
     assignments = service.assignment_repository.list_by_user(user_id=user.id)
     roles = []
     roles = []
@@ -299,5 +299,5 @@ def revoke_api_key(
     service: IdentityServiceDep) -> ApiResponse[ApiKeyDto]:
     service: IdentityServiceDep) -> ApiResponse[ApiKeyDto]:
     entity = service.revoke_api_key(api_key_id=payload.apiKeyId)
     entity = service.revoke_api_key(api_key_id=payload.apiKeyId)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"api key not found: {payload.apiKeyId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.api_key.not_found", id=payload.apiKeyId))
     return ok(request, ApiKeyDto.from_entity(entity))
     return ok(request, ApiKeyDto.from_entity(entity))

+ 2 - 1
services/code-runner-service/app/api/routes.py

@@ -1,4 +1,5 @@
 from core_domain import CodeExecutionRequestContract, CodeExecutionResponseContract, ServiceHealth
 from core_domain import CodeExecutionRequestContract, CodeExecutionResponseContract, ServiceHealth
+from core_shared import error_detail
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
 
 
 from app.application.services import CodeRunnerApplicationService
 from app.application.services import CodeRunnerApplicationService
@@ -32,4 +33,4 @@ def execute_code(
     try:
     try:
         return service.execute_code(payload)
         return service.execute_code(payload)
     except CodeRunnerError as exc:
     except CodeRunnerError as exc:
-        raise HTTPException(status_code=422, detail=str(exc)) from exc
+        raise HTTPException(status_code=422, detail=error_detail("error.code_runner.execution_failed", message=str(exc))) from exc

+ 3 - 2
services/event-service/app/api/routes.py

@@ -1,5 +1,6 @@
 from core_domain import ServiceHealth
 from core_domain import ServiceHealth
 from core_events import EventDeliveryStatus
 from core_events import EventDeliveryStatus
+from core_shared import error_detail
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import text
 from sqlalchemy import text
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
@@ -108,7 +109,7 @@ def update_delivery_status(
         event_record_id=event_record_id,
         event_record_id=event_record_id,
         payload=payload)
         payload=payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"event not found: {event_record_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.event.not_found", id=event_record_id))
     return EventRecordResponse.from_entity(entity)
     return EventRecordResponse.from_entity(entity)
 
 
 
 
@@ -122,7 +123,7 @@ def update_delivery_status_post(
             status=payload.status,
             status=payload.status,
             last_error_message=payload.last_error_message))
             last_error_message=payload.last_error_message))
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"event not found: {payload.event_record_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.event.not_found", id=payload.event_record_id))
     return EventRecordResponse.from_entity(entity)
     return EventRecordResponse.from_entity(entity)
 
 
 
 

+ 7 - 6
services/human-service/app/api/routes.py

@@ -1,4 +1,5 @@
 from core_domain import HumanTaskStatus, ServiceHealth
 from core_domain import HumanTaskStatus, ServiceHealth
+from core_shared import error_detail
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import text
 from sqlalchemy import text
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
@@ -74,7 +75,7 @@ def get_human_task(
     service: HumanApplicationService = Depends(get_human_application_service)) -> HumanTaskResponse:
     service: HumanApplicationService = Depends(get_human_application_service)) -> HumanTaskResponse:
     entity = service.get_task(human_task_id=human_task_id)
     entity = service.get_task(human_task_id=human_task_id)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"human task not found: {human_task_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.human_task.not_found", id=human_task_id))
     return HumanTaskResponse.from_entity(entity)
     return HumanTaskResponse.from_entity(entity)
 
 
 
 
@@ -84,7 +85,7 @@ def get_human_task_post(
     service: HumanApplicationService = Depends(get_human_application_service)) -> HumanTaskResponse:
     service: HumanApplicationService = Depends(get_human_application_service)) -> HumanTaskResponse:
     entity = service.get_task(human_task_id=payload.human_task_id)
     entity = service.get_task(human_task_id=payload.human_task_id)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"human task not found: {payload.human_task_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.human_task.not_found", id=payload.human_task_id))
     return HumanTaskResponse.from_entity(entity)
     return HumanTaskResponse.from_entity(entity)
 
 
 
 
@@ -95,7 +96,7 @@ def claim_human_task(
     service: HumanApplicationService = Depends(get_human_application_service)) -> HumanTaskResponse:
     service: HumanApplicationService = Depends(get_human_application_service)) -> HumanTaskResponse:
     entity = service.claim_task(human_task_id=human_task_id, payload=payload)
     entity = service.claim_task(human_task_id=human_task_id, payload=payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"human task not found: {human_task_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.human_task.not_found", id=human_task_id))
     return HumanTaskResponse.from_entity(entity)
     return HumanTaskResponse.from_entity(entity)
 
 
 
 
@@ -107,7 +108,7 @@ def claim_human_task_post(
         human_task_id=payload.human_task_id,
         human_task_id=payload.human_task_id,
         payload=HumanTaskClaimRequest(claimed_by=payload.claimed_by))
         payload=HumanTaskClaimRequest(claimed_by=payload.claimed_by))
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"human task not found: {payload.human_task_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.human_task.not_found", id=payload.human_task_id))
     return HumanTaskResponse.from_entity(entity)
     return HumanTaskResponse.from_entity(entity)
 
 
 
 
@@ -118,7 +119,7 @@ def complete_human_task(
     service: HumanApplicationService = Depends(get_human_application_service)) -> HumanTaskResponse:
     service: HumanApplicationService = Depends(get_human_application_service)) -> HumanTaskResponse:
     entity = service.complete_task(human_task_id=human_task_id, payload=payload)
     entity = service.complete_task(human_task_id=human_task_id, payload=payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"human task not found: {human_task_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.human_task.not_found", id=human_task_id))
     return HumanTaskResponse.from_entity(entity)
     return HumanTaskResponse.from_entity(entity)
 
 
 
 
@@ -132,5 +133,5 @@ def complete_human_task_post(
             status=payload.status,
             status=payload.status,
             response_payload_json=payload.response_payload_json))
             response_payload_json=payload.response_payload_json))
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"human task not found: {payload.human_task_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.human_task.not_found", id=payload.human_task_id))
     return HumanTaskResponse.from_entity(entity)
     return HumanTaskResponse.from_entity(entity)

+ 26 - 25
services/knowledge-service/app/api/routes.py

@@ -2,6 +2,7 @@ from datetime import datetime
 from typing import TypeVar
 from typing import TypeVar
 
 
 from core_domain import ServiceHealth
 from core_domain import ServiceHealth
+from core_shared import error_detail
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import text
 from sqlalchemy import text
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
@@ -145,7 +146,7 @@ def detail_base_contract(
     service: KnowledgeApplicationService = Depends(get_knowledge_application_service)) -> ApiResponse[KnowledgeBaseDto]:
     service: KnowledgeApplicationService = Depends(get_knowledge_application_service)) -> ApiResponse[KnowledgeBaseDto]:
     entity = service.base_repository.get_by_id(knowledge_base_id=payload.knowledgeBaseId)
     entity = service.base_repository.get_by_id(knowledge_base_id=payload.knowledgeBaseId)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"knowledge base not found: {payload.knowledgeBaseId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.knowledge_base.not_found", id=payload.knowledgeBaseId))
     return ok(KnowledgeBaseDto.from_entity(entity))
     return ok(KnowledgeBaseDto.from_entity(entity))
 
 
 
 
@@ -155,7 +156,7 @@ def update_base_contract(
     service: KnowledgeApplicationService = Depends(get_knowledge_application_service)) -> ApiResponse[KnowledgeBaseDto]:
     service: KnowledgeApplicationService = Depends(get_knowledge_application_service)) -> ApiResponse[KnowledgeBaseDto]:
     entity = service.update_base_from_contract(payload)
     entity = service.update_base_from_contract(payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"knowledge base not found: {payload.knowledgeBaseId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.knowledge_base.not_found", id=payload.knowledgeBaseId))
     return ok(KnowledgeBaseDto.from_entity(entity))
     return ok(KnowledgeBaseDto.from_entity(entity))
 
 
 
 
@@ -167,7 +168,7 @@ def update_base_status_contract(
         knowledge_base_id=payload.knowledgeBaseId,
         knowledge_base_id=payload.knowledgeBaseId,
         payload=KnowledgeBaseStatusUpdateRequest(status=payload.status))
         payload=KnowledgeBaseStatusUpdateRequest(status=payload.status))
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"knowledge base not found: {payload.knowledgeBaseId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.knowledge_base.not_found", id=payload.knowledgeBaseId))
     return ok(KnowledgeBaseDto.from_entity(entity))
     return ok(KnowledgeBaseDto.from_entity(entity))
 
 
 
 
@@ -178,7 +179,7 @@ def delete_base_contract(
     try:
     try:
         deleted = service.delete_base(knowledge_base_id=payload.knowledgeBaseId)
         deleted = service.delete_base(knowledge_base_id=payload.knowledgeBaseId)
     except ObjectStorageError as exc:
     except ObjectStorageError as exc:
-        raise HTTPException(status_code=503, detail=str(exc)) from exc
+        raise HTTPException(status_code=503, detail=error_detail("error.storage.unavailable", message=str(exc))) from exc
     return ok(DeleteData(
     return ok(DeleteData(
         deleted=deleted,
         deleted=deleted,
         knowledgeBaseId=payload.knowledgeBaseId))
         knowledgeBaseId=payload.knowledgeBaseId))
@@ -206,11 +207,11 @@ def create_document_contract(
     try:
     try:
         result = service.create_document_from_contract_result(payload)
         result = service.create_document_from_contract_result(payload)
     except KnowledgeIndexingError as exc:
     except KnowledgeIndexingError as exc:
-        raise HTTPException(status_code=503, detail=str(exc)) from exc
+        raise HTTPException(status_code=503, detail=error_detail("error.knowledge.indexing_failed", message=str(exc))) from exc
     except ObjectStorageError as exc:
     except ObjectStorageError as exc:
-        raise HTTPException(status_code=503, detail=str(exc)) from exc
+        raise HTTPException(status_code=503, detail=error_detail("error.storage.unavailable", message=str(exc))) from exc
     except ValueError as exc:
     except ValueError as exc:
-        raise HTTPException(status_code=422, detail=str(exc)) from exc
+        raise HTTPException(status_code=422, detail=error_detail("error.validation", message=str(exc))) from exc
     return ok(KnowledgeDocumentIngestData(
     return ok(KnowledgeDocumentIngestData(
         document=KnowledgeDocumentDto.from_entity(result.document),
         document=KnowledgeDocumentDto.from_entity(result.document),
         chunks=[KnowledgeChunkDto.from_entity(item) for item in result.chunks],
         chunks=[KnowledgeChunkDto.from_entity(item) for item in result.chunks],
@@ -224,7 +225,7 @@ def detail_document_contract(
     service: KnowledgeApplicationService = Depends(get_knowledge_application_service)) -> ApiResponse[KnowledgeDocumentDto]:
     service: KnowledgeApplicationService = Depends(get_knowledge_application_service)) -> ApiResponse[KnowledgeDocumentDto]:
     entity = service.document_repository.get_by_id(document_id=payload.documentId)
     entity = service.document_repository.get_by_id(document_id=payload.documentId)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"knowledge document not found: {payload.documentId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.knowledge_document.not_found", id=payload.documentId))
     return ok(KnowledgeDocumentDto.from_entity(entity))
     return ok(KnowledgeDocumentDto.from_entity(entity))
 
 
 
 
@@ -234,7 +235,7 @@ def update_document_contract(
     service: KnowledgeApplicationService = Depends(get_knowledge_application_service)) -> ApiResponse[KnowledgeDocumentDto]:
     service: KnowledgeApplicationService = Depends(get_knowledge_application_service)) -> ApiResponse[KnowledgeDocumentDto]:
     entity = service.update_document_from_contract(payload)
     entity = service.update_document_from_contract(payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"knowledge document not found: {payload.documentId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.knowledge_document.not_found", id=payload.documentId))
     return ok(KnowledgeDocumentDto.from_entity(entity))
     return ok(KnowledgeDocumentDto.from_entity(entity))
 
 
 
 
@@ -246,7 +247,7 @@ def update_document_status_contract(
         document_id=payload.documentId,
         document_id=payload.documentId,
         status=payload.status)
         status=payload.status)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"knowledge document not found: {payload.documentId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.knowledge_document.not_found", id=payload.documentId))
     return ok(KnowledgeDocumentDto.from_entity(entity))
     return ok(KnowledgeDocumentDto.from_entity(entity))
 
 
 
 
@@ -257,7 +258,7 @@ def delete_document_contract(
     try:
     try:
         result = service.delete_document_result(document_id=payload.documentId)
         result = service.delete_document_result(document_id=payload.documentId)
     except ObjectStorageError as exc:
     except ObjectStorageError as exc:
-        raise HTTPException(status_code=503, detail=str(exc)) from exc
+        raise HTTPException(status_code=503, detail=error_detail("error.storage.unavailable", message=str(exc))) from exc
     return ok(DeleteData(
     return ok(DeleteData(
         deleted=bool(result.get("deleted")),
         deleted=bool(result.get("deleted")),
         documentId=payload.documentId,
         documentId=payload.documentId,
@@ -271,13 +272,13 @@ def reindex_document_contract(
     try:
     try:
         result = service.reindex_document_from_contract_result(payload)
         result = service.reindex_document_from_contract_result(payload)
     except KnowledgeIndexingError as exc:
     except KnowledgeIndexingError as exc:
-        raise HTTPException(status_code=503, detail=str(exc)) from exc
+        raise HTTPException(status_code=503, detail=error_detail("error.knowledge.indexing_failed", message=str(exc))) from exc
     except ObjectStorageError as exc:
     except ObjectStorageError as exc:
-        raise HTTPException(status_code=503, detail=str(exc)) from exc
+        raise HTTPException(status_code=503, detail=error_detail("error.storage.unavailable", message=str(exc))) from exc
     except ValueError as exc:
     except ValueError as exc:
-        raise HTTPException(status_code=422, detail=str(exc)) from exc
+        raise HTTPException(status_code=422, detail=error_detail("error.validation", message=str(exc))) from exc
     if result is None:
     if result is None:
-        raise HTTPException(status_code=404, detail=f"knowledge document not found: {payload.documentId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.knowledge_document.not_found", id=payload.documentId))
     return ok(KnowledgeDocumentIngestData(
     return ok(KnowledgeDocumentIngestData(
         document=KnowledgeDocumentDto.from_entity(result.document),
         document=KnowledgeDocumentDto.from_entity(result.document),
         chunks=[KnowledgeChunkDto.from_entity(item) for item in result.chunks],
         chunks=[KnowledgeChunkDto.from_entity(item) for item in result.chunks],
@@ -302,7 +303,7 @@ def detail_index_job_contract(
     service: KnowledgeApplicationService = Depends(get_knowledge_application_service)) -> ApiResponse[KnowledgeIndexJobData]:
     service: KnowledgeApplicationService = Depends(get_knowledge_application_service)) -> ApiResponse[KnowledgeIndexJobData]:
     job = service.detail_index_job(document_id=payload.documentId)
     job = service.detail_index_job(document_id=payload.documentId)
     if job is None:
     if job is None:
-        raise HTTPException(status_code=404, detail=f"knowledge index job not found for document: {payload.documentId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.knowledge_index_job.not_found", id=payload.documentId))
     return ok(job)
     return ok(job)
 
 
 
 
@@ -317,7 +318,7 @@ def retry_index_job_contract(
             chunkOverlap=payload.chunkOverlap,
             chunkOverlap=payload.chunkOverlap,
             asyncMode=True))
             asyncMode=True))
     if result is None:
     if result is None:
-        raise HTTPException(status_code=404, detail=f"knowledge document not found: {payload.documentId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.knowledge_document.not_found", id=payload.documentId))
     return ok(KnowledgeDocumentIngestData(
     return ok(KnowledgeDocumentIngestData(
         document=KnowledgeDocumentDto.from_entity(result.document),
         document=KnowledgeDocumentDto.from_entity(result.document),
         chunks=[KnowledgeChunkDto.from_entity(item) for item in result.chunks],
         chunks=[KnowledgeChunkDto.from_entity(item) for item in result.chunks],
@@ -330,7 +331,7 @@ def reindex_base_contract(
     payload: KnowledgeBaseReindexRequestDto,
     payload: KnowledgeBaseReindexRequestDto,
     service: KnowledgeApplicationService = Depends(get_knowledge_application_service)) -> ApiResponse[KnowledgeBaseReindexData]:
     service: KnowledgeApplicationService = Depends(get_knowledge_application_service)) -> ApiResponse[KnowledgeBaseReindexData]:
     if service.base_repository.get_by_id(knowledge_base_id=payload.knowledgeBaseId) is None:
     if service.base_repository.get_by_id(knowledge_base_id=payload.knowledgeBaseId) is None:
-        raise HTTPException(status_code=404, detail=f"knowledge base not found: {payload.knowledgeBaseId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.knowledge_base.not_found", id=payload.knowledgeBaseId))
     jobs = service.reindex_base_from_contract(payload)
     jobs = service.reindex_base_from_contract(payload)
     return ok(KnowledgeBaseReindexData(
     return ok(KnowledgeBaseReindexData(
         knowledgeBaseId=payload.knowledgeBaseId,
         knowledgeBaseId=payload.knowledgeBaseId,
@@ -348,11 +349,11 @@ def read_document_content_contract(
             include_text=payload.includeText,
             include_text=payload.includeText,
             include_base64=payload.includeBase64)
             include_base64=payload.includeBase64)
     except ObjectStorageError as exc:
     except ObjectStorageError as exc:
-        raise HTTPException(status_code=503, detail=str(exc)) from exc
+        raise HTTPException(status_code=503, detail=error_detail("error.storage.unavailable", message=str(exc))) from exc
     except ValueError as exc:
     except ValueError as exc:
-        raise HTTPException(status_code=422, detail=str(exc)) from exc
+        raise HTTPException(status_code=422, detail=error_detail("error.validation", message=str(exc))) from exc
     if result is None:
     if result is None:
-        raise HTTPException(status_code=404, detail=f"knowledge document not found: {payload.documentId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.knowledge_document.not_found", id=payload.documentId))
     return ok(KnowledgeDocumentContentData.model_validate(result))
     return ok(KnowledgeDocumentContentData.model_validate(result))
 
 
 
 
@@ -363,9 +364,9 @@ def read_document_storage_status_contract(
     try:
     try:
         result = service.read_document_storage_status(document_id=payload.documentId)
         result = service.read_document_storage_status(document_id=payload.documentId)
     except ObjectStorageError as exc:
     except ObjectStorageError as exc:
-        raise HTTPException(status_code=503, detail=str(exc)) from exc
+        raise HTTPException(status_code=503, detail=error_detail("error.storage.unavailable", message=str(exc))) from exc
     if result is None:
     if result is None:
-        raise HTTPException(status_code=404, detail=f"knowledge document not found: {payload.documentId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.knowledge_document.not_found", id=payload.documentId))
     return ok(KnowledgeDocumentStorageStatusData.model_validate({
     return ok(KnowledgeDocumentStorageStatusData.model_validate({
         **result,
         **result,
         "checkedTime": datetime.utcnow(),
         "checkedTime": datetime.utcnow(),
@@ -384,7 +385,7 @@ def parse_document_contract(
                 content_text=payload.contentText,
                 content_text=payload.contentText,
                 content_base64=payload.contentBase64))
                 content_base64=payload.contentBase64))
     except DocumentParseError as exc:
     except DocumentParseError as exc:
-        raise HTTPException(status_code=422, detail=str(exc)) from exc
+        raise HTTPException(status_code=422, detail=error_detail("error.validation", message=str(exc))) from exc
     return ok(KnowledgeDocumentParseData(
     return ok(KnowledgeDocumentParseData(
         contentText=parsed.content_text,
         contentText=parsed.content_text,
         sourceType=parsed.source_type,
         sourceType=parsed.source_type,
@@ -411,7 +412,7 @@ def detail_chunk_contract(
     service: KnowledgeApplicationService = Depends(get_knowledge_application_service)) -> ApiResponse[KnowledgeChunkDto]:
     service: KnowledgeApplicationService = Depends(get_knowledge_application_service)) -> ApiResponse[KnowledgeChunkDto]:
     entity = service.chunk_repository.get_by_id(chunk_id=payload.chunkId)
     entity = service.chunk_repository.get_by_id(chunk_id=payload.chunkId)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"knowledge chunk not found: {payload.chunkId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.knowledge_chunk.not_found", id=payload.chunkId))
     return ok(KnowledgeChunkDto.from_entity(entity))
     return ok(KnowledgeChunkDto.from_entity(entity))
 
 
 
 

+ 108 - 0
services/knowledge-service/app/application/_storage_mixin.py

@@ -0,0 +1,108 @@
+"""Shared object storage helper mixin for knowledge sub-services."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from core_shared import JSONValue
+
+from app.bootstrap.settings import KnowledgeServiceSettings
+from app.db.models import KnowledgeDocument
+from app.infrastructure.object_storage import (
+    ObjectStorageStatus,
+    build_object_storage)
+
+if TYPE_CHECKING:
+    from app.infrastructure.object_storage import KnowledgeObjectStorage
+
+
+class _ObjectStorageMixin:
+    settings: KnowledgeServiceSettings
+    _object_storage: KnowledgeObjectStorage | None
+
+    @property
+    def object_storage(self) -> KnowledgeObjectStorage:
+        if self._object_storage is None:
+            self._object_storage = build_object_storage(self.settings)
+        return self._object_storage
+
+    def _read_document_object_key(self, *, document: KnowledgeDocument) -> str | None:
+        object_metadata = self._read_object_storage_metadata(document=document)
+        if object_metadata is None:
+            return None
+        object_key = object_metadata.get("objectKey")
+        return object_key if isinstance(object_key, str) and object_key else None
+
+    def _read_object_storage_metadata(
+        self,
+        *,
+        document: KnowledgeDocument,
+    ) -> dict[str, JSONValue] | None:
+        metadata = document.metadata_json or {}
+        object_metadata = metadata.get("object_storage")
+        return object_metadata if isinstance(object_metadata, dict) else None
+
+    def _read_document_raw_content(self, *, document: KnowledgeDocument) -> bytes:
+        object_key = self._read_document_object_key(document=document)
+        if isinstance(object_key, str) and object_key:
+            return self.object_storage.get_bytes(object_key=object_key)
+        if document.content_text:
+            return document.content_text.encode("utf-8")
+        raise ValueError(f"knowledge document content object not found: {document.id}")
+
+    def _delete_document_object(self, *, document: KnowledgeDocument) -> bool:
+        from app.infrastructure.object_storage import ObjectStorageNotFoundError
+
+        object_key = self._read_document_object_key(document=document)
+        if object_key is None:
+            return False
+        try:
+            return self.object_storage.delete_object(object_key=object_key)
+        except ObjectStorageNotFoundError:
+            return False
+
+    def _guess_content_type(self, *, source_type: str) -> str:
+        normalized = source_type.strip().lower().removeprefix(".")
+        if normalized in {"markdown", "md"}:
+            return "text/markdown; charset=utf-8"
+        if normalized in {"html", "htm"}:
+            return "text/html; charset=utf-8"
+        if normalized == "json":
+            return "application/json"
+        if normalized == "csv":
+            return "text/csv; charset=utf-8"
+        if normalized == "pdf":
+            return "application/pdf"
+        if normalized in {"docx", "word"}:
+            return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+        return "text/plain; charset=utf-8"
+
+    def _object_status_to_payload(
+        self,
+        *,
+        document: KnowledgeDocument,
+        status: ObjectStorageStatus,
+    ) -> dict[str, JSONValue]:
+        return {
+            "documentId": document.id,
+            "exists": status.exists,
+            "objectStorage": self._read_object_storage_metadata(document=document),
+            "contentType": status.content_type,
+            "sizeBytes": status.size_bytes,
+            "etag": status.etag,
+            "errorMessage": status.error_message,
+        }
+
+    def _read_content_type_from_status(
+        self,
+        object_status: dict[str, JSONValue] | None,
+    ) -> str | None:
+        if object_status is None:
+            return None
+        content_type = object_status.get("contentType")
+        return content_type if isinstance(content_type, str) else None
+
+    def _is_text_content_type(self, *, content_type: str | None, source_type: str) -> bool:
+        if content_type is not None and content_type.startswith("text/"):
+            return True
+        return source_type.strip().lower() in {"text", "txt", "markdown", "md", "html", "htm", "json", "csv"}

+ 1 - 2
services/knowledge-service/app/application/chunking.py

@@ -32,9 +32,8 @@ def chunk_document(
 ) -> list[dict[str, JSONValue]]:
 ) -> list[dict[str, JSONValue]]:
     """Dispatch to the appropriate chunker based on source_type."""
     """Dispatch to the appropriate chunker based on source_type."""
     normalized = source_type.strip().lower()
     normalized = source_type.strip().lower()
-    text_for_chunking = raw_content or content_text
     if normalized in {"markdown", "md"}:
     if normalized in {"markdown", "md"}:
-        chunks = _chunk_markdown(text_for_chunking, chunk_size=chunk_size, chunk_overlap=chunk_overlap)
+        chunks = _chunk_markdown(content_text, chunk_size=chunk_size, chunk_overlap=chunk_overlap)
     elif normalized == "json":
     elif normalized == "json":
         chunks = _chunk_json(content_text, chunk_size=chunk_size)
         chunks = _chunk_json(content_text, chunk_size=chunk_size)
     else:
     else:

+ 247 - 0
services/knowledge-service/app/application/crud_service.py

@@ -0,0 +1,247 @@
+"""Knowledge CRUD sub-service — bases, documents, chunks, content reading."""
+
+from __future__ import annotations
+
+import base64
+from typing import TYPE_CHECKING
+
+from core_shared import JSONValue
+
+from app.application._storage_mixin import _ObjectStorageMixin
+from app.application.document_parsers import (
+    DocumentParseError,
+    ParsedDocument,
+    parse_document_content)
+from app.bootstrap.settings import KnowledgeServiceSettings
+from app.db.models import KnowledgeBase, KnowledgeChunk, KnowledgeDocument
+from app.schemas.knowledge import (
+    KnowledgeBaseCreateRequest,
+    KnowledgeBaseCreateRequestDto,
+    KnowledgeBaseStatusUpdateRequest,
+    KnowledgeBaseUpdateRequestDto,
+    KnowledgeDocumentCreateRequestDto,
+    KnowledgeDocumentParseRequest,
+    KnowledgeDocumentUpdateRequestDto)
+
+if TYPE_CHECKING:
+    from app.domain.repositories import (
+        KnowledgeBaseRepository,
+        KnowledgeChunkRepository,
+        KnowledgeDocumentRepository)
+    from app.infrastructure.object_storage import KnowledgeObjectStorage
+
+
+class KnowledgeCrudService(_ObjectStorageMixin):
+    def __init__(
+        self,
+        *,
+        settings: KnowledgeServiceSettings,
+        base_repository: KnowledgeBaseRepository,
+        document_repository: KnowledgeDocumentRepository,
+        chunk_repository: KnowledgeChunkRepository,
+        object_storage: KnowledgeObjectStorage | None = None,
+    ) -> None:
+        self.settings = settings
+        self.base_repository = base_repository
+        self.document_repository = document_repository
+        self.chunk_repository = chunk_repository
+        self._object_storage = object_storage
+
+    # ── Knowledge Base CRUD ──────────────────────────────────────────
+
+    def create_base(self, payload: KnowledgeBaseCreateRequest) -> KnowledgeBase:
+        return self.base_repository.create(
+            code=payload.code,
+            name=payload.name,
+            description=payload.description,
+            metadata_json=payload.metadata_json)
+
+    def create_base_from_contract(
+        self,
+        payload: KnowledgeBaseCreateRequestDto,
+    ) -> KnowledgeBase:
+        return self.create_base(KnowledgeBaseCreateRequest(
+            code=self._build_base_code(payload.name),
+            name=payload.name,
+            description=payload.description,
+            metadata_json=payload.metadata))
+
+    def list_bases(self) -> list[KnowledgeBase]:
+        return self.base_repository.list_all()
+
+    def list_bases_filtered(
+        self,
+        *,
+        keyword: str | None = None,
+        status: str | None = None,
+    ) -> list[KnowledgeBase]:
+        return self.base_repository.list_filtered(keyword=keyword, status=status)
+
+    def update_base_from_contract(
+        self,
+        payload: KnowledgeBaseUpdateRequestDto,
+    ) -> KnowledgeBase | None:
+        return self.base_repository.update(
+            knowledge_base_id=payload.knowledgeBaseId,
+            name=payload.name,
+            description=payload.description,
+            status=payload.status,
+            metadata_json=payload.metadata)
+
+    def delete_base(self, *, knowledge_base_id: str) -> bool:
+        documents = self.document_repository.list_by_base(knowledge_base_id=knowledge_base_id)
+        for document in documents:
+            self._delete_document_object(document=document)
+        self.chunk_repository.delete_by_base(knowledge_base_id=knowledge_base_id)
+        for document in documents:
+            self.document_repository.delete(document_id=document.id)
+        return self.base_repository.delete(knowledge_base_id=knowledge_base_id)
+
+    def update_base_status(
+        self,
+        *,
+        knowledge_base_id: str,
+        payload: KnowledgeBaseStatusUpdateRequest,
+    ) -> KnowledgeBase | None:
+        return self.base_repository.update_status(
+            knowledge_base_id=knowledge_base_id,
+            status=payload.status)
+
+    # ── Document CRUD ────────────────────────────────────────────────
+
+    def list_documents(
+        self,
+        *,
+        knowledge_base_id: str,
+    ) -> list[KnowledgeDocument]:
+        return self.document_repository.list_by_base(knowledge_base_id=knowledge_base_id)
+
+    def list_documents_filtered(
+        self,
+        *,
+        knowledge_base_id: str | None = None,
+        keyword: str | None = None,
+        status: str | None = None,
+        source_type: str | None = None,
+    ) -> list[KnowledgeDocument]:
+        return self.document_repository.list_filtered(
+            knowledge_base_id=knowledge_base_id,
+            keyword=keyword,
+            status=status,
+            source_type=source_type)
+
+    def update_document_from_contract(
+        self,
+        payload: KnowledgeDocumentUpdateRequestDto,
+    ) -> KnowledgeDocument | None:
+        return self.document_repository.update(
+            document_id=payload.documentId,
+            title=payload.title,
+            source_uri=payload.sourceUri,
+            status=payload.status,
+            metadata_json=payload.metadata)
+
+    def delete_document(self, *, document_id: str) -> bool:
+        return bool(self.delete_document_result(document_id=document_id)["deleted"])
+
+    def delete_document_result(self, *, document_id: str) -> dict[str, JSONValue]:
+        document = self.document_repository.get_by_id(document_id=document_id)
+        if document is None:
+            return {
+                "deleted": False,
+                "objectDeleted": False,
+                "documentId": document_id,
+            }
+        object_deleted = self._delete_document_object(document=document)
+        self.chunk_repository.delete_by_document(document_id=document_id)
+        deleted = self.document_repository.delete(document_id=document_id) is not None
+        return {
+            "deleted": deleted,
+            "objectDeleted": object_deleted,
+            "documentId": document_id,
+        }
+
+    def read_document_content(
+        self,
+        *,
+        document_id: str,
+        include_text: bool = True,
+        include_base64: bool = False,
+    ) -> dict[str, JSONValue] | None:
+        document = self.document_repository.get_by_id(document_id=document_id)
+        if document is None:
+            return None
+        raw_content = self._read_document_raw_content(document=document)
+        object_status = self.read_document_storage_status(document_id=document_id)
+        content_type = self._read_content_type_from_status(object_status)
+        payload: dict[str, JSONValue] = {
+            "documentId": document.id,
+            "title": document.title,
+            "sourceType": document.source_type,
+            "contentType": content_type,
+            "sizeBytes": len(raw_content),
+            "objectStorage": self._read_object_storage_metadata(document=document),
+            "contentBase64": None,
+            "contentText": None,
+        }
+        if include_base64:
+            payload["contentBase64"] = base64.b64encode(raw_content).decode("ascii")
+        if include_text and self._is_text_content_type(content_type=content_type, source_type=document.source_type):
+            payload["contentText"] = raw_content.decode("utf-8", errors="replace")
+        return payload
+
+    def read_document_storage_status(self, *, document_id: str) -> dict[str, JSONValue] | None:
+        document = self.document_repository.get_by_id(document_id=document_id)
+        if document is None:
+            return None
+        object_key = self._read_document_object_key(document=document)
+        if object_key is None:
+            return {
+                "documentId": document.id,
+                "exists": False,
+                "objectStorage": None,
+                "errorMessage": "document object metadata is missing",
+            }
+        status = self.object_storage.head_object(object_key=object_key)
+        return self._object_status_to_payload(document=document, status=status)
+
+    def read_storage_health(self) -> dict[str, JSONValue]:
+        return dict(self.object_storage.health_check())
+
+    # ── Chunk CRUD ───────────────────────────────────────────────────
+
+    def list_chunks_filtered(
+        self,
+        *,
+        knowledge_base_id: str | None = None,
+        document_id: str | None = None,
+        keyword: str | None = None,
+    ) -> list[KnowledgeChunk]:
+        return self.chunk_repository.list_filtered(
+            knowledge_base_id=knowledge_base_id,
+            document_id=document_id,
+            keyword=keyword)
+
+    def delete_chunk(self, *, chunk_id: str) -> bool:
+        return self.chunk_repository.delete(chunk_id=chunk_id)
+
+    # ── Parse ────────────────────────────────────────────────────────
+
+    def parse_document(self, payload: KnowledgeDocumentParseRequest) -> ParsedDocument:
+        try:
+            return parse_document_content(
+                source_type=payload.source_type,
+                content_text=payload.content_text,
+                content_base64=payload.content_base64,
+                source_uri=payload.source_uri)
+        except DocumentParseError:
+            raise
+
+    # ── Private helpers ──────────────────────────────────────────────
+
+    def _build_base_code(self, name: str) -> str:
+        base = "".join(
+            char.lower() if char.isalnum() else "_"
+            for char in name
+        ).strip("_") or "knowledge_base"
+        return base[:64]

+ 809 - 0
services/knowledge-service/app/application/indexing_service.py

@@ -0,0 +1,809 @@
+"""Knowledge indexing sub-service — document creation, chunking, embedding, job queue."""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+from typing import TYPE_CHECKING, cast
+from uuid import uuid4
+
+from core_shared import JSONValue
+from core_shared.task_queue import KNOWLEDGE_DOCUMENT_QUEUE, TaskQueuePublisher
+
+from app.application._storage_mixin import _ObjectStorageMixin
+from app.application.chunking import chunk_document
+from app.application.document_parsers import (
+    normalize_source_type,
+    parse_document_content,
+    read_document_content_bytes)
+from app.application.embeddings import EmbeddingService
+from app.application.retrieval import stable_content_hash
+from app.bootstrap.settings import KnowledgeServiceSettings
+from app.db.models import KnowledgeChunk, KnowledgeDocument
+from app.infrastructure.object_storage import (
+    ObjectStorageError,
+    ObjectStorageNotFoundError,
+    build_document_object_key)
+from app.schemas.knowledge import (
+    KnowledgeDocumentCreateRequest,
+    KnowledgeDocumentCreateRequestDto,
+    KnowledgeDocumentReindexRequestDto,
+    KnowledgeIndexJobAction,
+    KnowledgeIndexJobData,
+    KnowledgeIndexJobStatus)
+
+if TYPE_CHECKING:
+    from redis import Redis
+
+    from app.domain.repositories import (
+        KnowledgeBaseRepository,
+        KnowledgeChunkRepository,
+        KnowledgeDocumentRepository)
+    from app.infrastructure.object_storage import KnowledgeObjectStorage
+
+
+@dataclass(frozen=True, slots=True)
+class KnowledgeDocumentIngestResult:
+    document: KnowledgeDocument
+    chunks: list[KnowledgeChunk]
+    queued: bool = False
+    job: KnowledgeIndexJobData | None = None
+
+
+class KnowledgeIndexingError(RuntimeError):
+    def __init__(self, *, document_id: str, message: str) -> None:
+        super().__init__(message)
+        self.document_id = document_id
+
+
+class KnowledgeIndexingService(_ObjectStorageMixin):
+    def __init__(
+        self,
+        *,
+        settings: KnowledgeServiceSettings,
+        base_repository: KnowledgeBaseRepository,
+        document_repository: KnowledgeDocumentRepository,
+        chunk_repository: KnowledgeChunkRepository,
+        embedding_service: EmbeddingService,
+        object_storage: KnowledgeObjectStorage | None = None,
+        redis_client: Redis | None = None,
+        task_queue_publisher: TaskQueuePublisher | None = None,
+    ) -> None:
+        self.settings = settings
+        self.base_repository = base_repository
+        self.document_repository = document_repository
+        self.chunk_repository = chunk_repository
+        self.embedding_service = embedding_service
+        self._object_storage = object_storage
+        self.redis_client = redis_client
+        self.task_queue_publisher = task_queue_publisher
+
+    # ── Document creation (with indexing) ─────────────────────────────
+
+    def create_document(
+        self,
+        payload: KnowledgeDocumentCreateRequest,
+    ) -> tuple[KnowledgeDocument, list[KnowledgeChunk]]:
+        from app.application.document_parsers import ParsedDocument
+
+        knowledge_base = self.base_repository.get_by_id(
+            knowledge_base_id=payload.knowledge_base_id)
+        if knowledge_base is None:
+            raise ValueError(f"knowledge base not found: {payload.knowledge_base_id}")
+
+        parsed = parse_document_content(
+            source_type=payload.source_type,
+            source_uri=payload.source_uri,
+            content_text=payload.content_text,
+            content_base64=payload.content_base64)
+        raw_content = read_document_content_bytes(
+            content_text=payload.content_text,
+            content_base64=payload.content_base64)
+        object_key = build_document_object_key(
+            knowledge_base_id=payload.knowledge_base_id,
+            source_type=parsed.source_type,
+            title=payload.title)
+        stored_object = self.object_storage.put_bytes(
+            object_key=object_key,
+            content=raw_content,
+            content_type=self._guess_content_type(source_type=parsed.source_type))
+        document: KnowledgeDocument | None = None
+        try:
+            metadata_json = {
+                **payload.metadata_json,
+                "parser_metadata": parsed.metadata_json,
+                "object_storage": stored_object.to_metadata(),
+            }
+            document = self.document_repository.create(
+                knowledge_base_id=payload.knowledge_base_id,
+                title=payload.title,
+                source_type=parsed.source_type,
+                source_uri=payload.source_uri,
+                content_text="",
+                content_hash=stable_content_hash(parsed.content_text),
+                metadata_json=metadata_json)
+            try:
+                chunks = self._index_document(
+                    document=document,
+                    content_text=parsed.content_text,
+                    chunk_size=payload.chunk_size,
+                    chunk_overlap=payload.chunk_overlap)
+            except Exception as exc:
+                self._mark_document_failed(document=document, message=str(exc))
+                raise KnowledgeIndexingError(
+                    document_id=document.id,
+                    message=f"knowledge document indexing failed: {exc}") from exc
+            indexed_document = self.document_repository.update_status(
+                document_id=document.id,
+                status="indexed")
+            return indexed_document or document, chunks
+        except Exception:
+            if document is None:
+                try:
+                    self.object_storage.delete_object(object_key=stored_object.object_key)
+                except ObjectStorageError:
+                    pass
+            raise
+
+    def create_document_from_contract(
+        self,
+        payload: KnowledgeDocumentCreateRequestDto,
+    ) -> tuple[KnowledgeDocument, list[KnowledgeChunk]]:
+        return self.create_document(KnowledgeDocumentCreateRequest(
+            knowledge_base_id=payload.knowledgeBaseId,
+            title=payload.title,
+            content_text=payload.contentText,
+            content_base64=payload.contentBase64,
+            source_type=payload.sourceType,
+            source_uri=payload.sourceUri,
+            metadata_json=payload.metadata,
+            chunk_size=payload.chunkSize,
+            chunk_overlap=payload.chunkOverlap))
+
+    def create_document_from_contract_result(
+        self,
+        payload: KnowledgeDocumentCreateRequestDto,
+    ) -> KnowledgeDocumentIngestResult:
+        request = KnowledgeDocumentCreateRequest(
+            knowledge_base_id=payload.knowledgeBaseId,
+            title=payload.title,
+            content_text=payload.contentText,
+            content_base64=payload.contentBase64,
+            source_type=payload.sourceType,
+            source_uri=payload.sourceUri,
+            metadata_json=payload.metadata,
+            chunk_size=payload.chunkSize,
+            chunk_overlap=payload.chunkOverlap)
+        if self._should_queue_indexing(async_mode=payload.asyncMode):
+            return self.create_document_index_job(payload=request)
+        document, chunks = self.create_document(request)
+        return KnowledgeDocumentIngestResult(document=document, chunks=chunks)
+
+    def create_document_index_job(
+        self,
+        payload: KnowledgeDocumentCreateRequest,
+    ) -> KnowledgeDocumentIngestResult:
+        knowledge_base = self.base_repository.get_by_id(
+            knowledge_base_id=payload.knowledge_base_id)
+        if knowledge_base is None:
+            raise ValueError(f"knowledge base not found: {payload.knowledge_base_id}")
+
+        raw_content = read_document_content_bytes(
+            content_text=payload.content_text,
+            content_base64=payload.content_base64)
+        source_type = normalize_source_type(
+            source_type=payload.source_type,
+            source_uri=payload.source_uri)
+        object_key = build_document_object_key(
+            knowledge_base_id=payload.knowledge_base_id,
+            source_type=source_type,
+            title=payload.title)
+        stored_object = self.object_storage.put_bytes(
+            object_key=object_key,
+            content=raw_content,
+            content_type=self._guess_content_type(source_type=source_type))
+        document: KnowledgeDocument | None = None
+        try:
+            document = self.document_repository.create(
+                knowledge_base_id=payload.knowledge_base_id,
+                title=payload.title,
+                source_type=source_type,
+                source_uri=payload.source_uri,
+                content_text="",
+                content_hash=hashlib.sha256(raw_content).hexdigest(),
+                metadata_json={
+                    **payload.metadata_json,
+                    "object_storage": stored_object.to_metadata(),
+                },
+                status="draft")
+            queued_document, job = self.queue_document_indexing(
+                document=document,
+                action="index",
+                chunk_size=payload.chunk_size,
+                chunk_overlap=payload.chunk_overlap)
+        except Exception:
+            if document is None:
+                try:
+                    self.object_storage.delete_object(object_key=stored_object.object_key)
+                except ObjectStorageError:
+                    pass
+            raise
+        return KnowledgeDocumentIngestResult(
+            document=queued_document,
+            chunks=[],
+            queued=True,
+            job=job)
+
+    # ── Reindexing ────────────────────────────────────────────────────
+
+    def reindex_document(
+        self,
+        payload: KnowledgeDocumentReindexRequestDto,
+    ) -> tuple[KnowledgeDocument, list[KnowledgeChunk]] | None:
+        document = self.document_repository.get_by_id(document_id=payload.documentId)
+        if document is None:
+            return None
+        try:
+            parsed = self._parse_document_for_indexing(document=document)
+            chunks = self._index_document(
+                document=document,
+                content_text=parsed.content_text,
+                chunk_size=payload.chunkSize,
+                chunk_overlap=payload.chunkOverlap)
+        except Exception as exc:
+            self._mark_document_failed(document=document, message=str(exc))
+            raise KnowledgeIndexingError(
+                document_id=document.id,
+                message=f"knowledge document reindex failed: {exc}") from exc
+        metadata = dict(document.metadata_json or {})
+        metadata["parser_metadata"] = parsed.metadata_json
+        indexed_document = self.document_repository.update(
+            document_id=document.id,
+            status="indexed",
+            metadata_json=metadata)
+        return indexed_document or document, chunks
+
+    def reindex_document_from_contract_result(
+        self,
+        payload: KnowledgeDocumentReindexRequestDto,
+    ) -> KnowledgeDocumentIngestResult | None:
+        if self._should_queue_indexing(async_mode=payload.asyncMode):
+            document = self.document_repository.get_by_id(document_id=payload.documentId)
+            if document is None:
+                return None
+            queued_document, job = self.queue_document_indexing(
+                document=document,
+                action="reindex",
+                chunk_size=payload.chunkSize,
+                chunk_overlap=payload.chunkOverlap)
+            return KnowledgeDocumentIngestResult(
+                document=queued_document,
+                chunks=[],
+                queued=True,
+                job=job)
+        result = self.reindex_document(payload)
+        if result is None:
+            return None
+        document, chunks = result
+        return KnowledgeDocumentIngestResult(document=document, chunks=chunks)
+
+    def reindex_base_from_contract(
+        self,
+        payload,
+    ) -> list[KnowledgeIndexJobData]:
+        from app.schemas.knowledge import KnowledgeBaseReindexRequestDto
+        documents = self.document_repository.list_by_base(
+            knowledge_base_id=payload.knowledgeBaseId)
+        jobs: list[KnowledgeIndexJobData] = []
+        for document in documents:
+            if document.status == "archived":
+                continue
+            queued_document, job = self.queue_document_indexing(
+                document=document,
+                action="reindex",
+                chunk_size=payload.chunkSize,
+                chunk_overlap=payload.chunkOverlap)
+            jobs.append(job or self._read_latest_index_job(document=queued_document))
+        return jobs
+
+    # ── Job queue management ──────────────────────────────────────────
+
+    def queue_document_indexing(
+        self,
+        *,
+        document: KnowledgeDocument,
+        action: str,
+        chunk_size: int | None = None,
+        chunk_overlap: int | None = None,
+    ) -> tuple[KnowledgeDocument, KnowledgeIndexJobData]:
+        job_id = f"kjob_{uuid4().hex}"
+        metadata = self._write_index_job_metadata(
+            document=document,
+            action=action,
+            job_id=job_id,
+            status="queued",
+            progress=0,
+            chunk_size=chunk_size,
+            chunk_overlap=chunk_overlap)
+        updated_document = self.document_repository.update(
+            document_id=document.id,
+            status="queued",
+            metadata_json=metadata)
+        document_for_job = updated_document or document
+        published = self._publish_document_index_job(
+            document_id=document.id,
+            action=action,
+            job_id=job_id)
+        if not published:
+            metadata = self._write_index_job_metadata(
+                document=document_for_job,
+                action=action,
+                job_id=job_id,
+                status="running",
+                progress=1,
+                chunk_size=chunk_size,
+                chunk_overlap=chunk_overlap,
+                worker_key="inline-fallback")
+            document_for_job = self.document_repository.update(
+                document_id=document.id,
+                status="indexing",
+                metadata_json=metadata) or document_for_job
+            processed = self.process_document_index_job(
+                document_id=document.id,
+                action=action,
+                job_id=job_id,
+                worker_key="inline-fallback",
+                chunk_size=chunk_size,
+                chunk_overlap=chunk_overlap)
+            if processed is not None:
+                indexed_document, chunks = processed
+                return indexed_document, self._read_latest_index_job(document=indexed_document)
+        return document_for_job, self._read_latest_index_job(document=document_for_job)
+
+    def process_document_index_job(
+        self,
+        *,
+        document_id: str,
+        action: str,
+        worker_key: str,
+        job_id: str | None = None,
+        chunk_size: int | None = None,
+        chunk_overlap: int | None = None,
+    ) -> tuple[KnowledgeDocument, list[KnowledgeChunk]] | None:
+        document = self.document_repository.get_by_id(document_id=document_id)
+        if document is None:
+            return None
+        resolved_job = self._read_latest_index_job(document=document)
+        resolved_job_id = job_id or resolved_job.jobId
+        resolved_chunk_size = chunk_size if chunk_size is not None else resolved_job.chunkSize
+        resolved_chunk_overlap = chunk_overlap if chunk_overlap is not None else resolved_job.chunkOverlap
+        if document.status == "archived":
+            metadata = self._write_index_job_metadata(
+                document=document,
+                action=action,
+                job_id=resolved_job_id,
+                status="skipped",
+                progress=100,
+                chunk_size=resolved_chunk_size,
+                chunk_overlap=resolved_chunk_overlap,
+                worker_key=worker_key,
+                completed_time=datetime.utcnow(),
+                error_message="document is archived")
+            skipped_document = self.document_repository.update(
+                document_id=document.id,
+                metadata_json=metadata) or document
+            return skipped_document, []
+
+        metadata = self._write_index_job_metadata(
+            document=document,
+            action=action,
+            job_id=resolved_job_id,
+            status="running",
+            progress=10,
+            chunk_size=resolved_chunk_size,
+            chunk_overlap=resolved_chunk_overlap,
+            worker_key=worker_key,
+            started_time=datetime.utcnow())
+        running_document = self.document_repository.update(
+            document_id=document.id,
+            status="indexing",
+            metadata_json=metadata) or document
+        try:
+            parsed = self._parse_document_for_indexing(document=running_document)
+            metadata = self._write_index_job_metadata(
+                document=running_document,
+                action=action,
+                job_id=resolved_job_id,
+                status="running",
+                progress=40,
+                chunk_size=resolved_chunk_size,
+                chunk_overlap=resolved_chunk_overlap,
+                worker_key=worker_key,
+                started_time=self._read_latest_index_job(document=running_document).startedTime)
+            metadata["parser_metadata"] = parsed.metadata_json
+            running_document = self.document_repository.update(
+                document_id=document.id,
+                status="indexing",
+                metadata_json=metadata) or running_document
+            chunks = self._index_document(
+                document=running_document,
+                content_text=parsed.content_text,
+                chunk_size=resolved_chunk_size,
+                chunk_overlap=resolved_chunk_overlap)
+        except Exception as exc:
+            self._mark_document_failed(
+                document=running_document,
+                message=str(exc),
+                job_id=resolved_job_id,
+                action=action,
+                worker_key=worker_key,
+                chunk_size=resolved_chunk_size,
+                chunk_overlap=resolved_chunk_overlap)
+            raise KnowledgeIndexingError(
+                document_id=document.id,
+                message=f"knowledge document {action} failed: {exc}") from exc
+
+        metadata = self._write_index_job_metadata(
+            document=running_document,
+            action=action,
+            job_id=resolved_job_id,
+            status="completed",
+            progress=100,
+            chunk_size=resolved_chunk_size,
+            chunk_overlap=resolved_chunk_overlap,
+            worker_key=worker_key,
+            completed_time=datetime.utcnow())
+        metadata["parser_metadata"] = parsed.metadata_json
+        indexed_document = self.document_repository.update(
+            document_id=document.id,
+            status="indexed",
+            metadata_json=metadata)
+        return indexed_document or running_document, chunks
+
+    def execute_document_index_job(
+        self,
+        *,
+        document_id: str,
+        action: str,
+        worker_key: str,
+        lease_seconds: int,
+        job_id: str | None = None,
+        redis_client: Redis | None = None,
+    ) -> tuple[KnowledgeDocument, list[KnowledgeChunk]] | None:
+        resolved_redis_client = redis_client or self.redis_client
+        idempotency_key = f"{document_id}:{job_id or action}"
+        lock = None
+        idempotency_store = None
+        if resolved_redis_client is not None:
+            from core_shared.redis_primitives import DistributedLock, IdempotencyStore
+
+            lock = DistributedLock(
+                client=resolved_redis_client,
+                name=f"knowledge-document:{document_id}:lock",
+                ttl_seconds=lease_seconds)
+            if not lock.acquire():
+                return None
+            idempotency_store = IdempotencyStore(
+                client=resolved_redis_client,
+                prefix="knowledge-document-idempotency")
+            if not idempotency_store.begin(key=idempotency_key):
+                lock.release()
+                return None
+        try:
+            result = self.process_document_index_job(
+                document_id=document_id,
+                action=action,
+                job_id=job_id,
+                worker_key=worker_key)
+            if idempotency_store is not None and result is not None:
+                document, chunks = result
+                idempotency_store.complete(
+                    key=idempotency_key,
+                    result={
+                        "status": document.status,
+                        "document_id": document.id,
+                        "chunk_count": len(chunks),
+                    })
+        except Exception:
+            if idempotency_store is not None:
+                idempotency_store.clear(key=idempotency_key)
+            raise
+        finally:
+            if lock is not None:
+                lock.release()
+        return result
+
+    def execute_next_pending_document_job(
+        self,
+        *,
+        worker_key: str,
+        lease_seconds: int,
+        stale_indexing_seconds: int,
+        redis_client: Redis | None = None,
+    ) -> tuple[KnowledgeDocument, list[KnowledgeChunk]] | None:
+        stale_before = datetime.utcnow() - timedelta(seconds=stale_indexing_seconds)
+        document = self.document_repository.get_next_pending_indexing(stale_before=stale_before)
+        if document is None:
+            return None
+        job = self._read_latest_index_job(document=document)
+        return self.execute_document_index_job(
+            document_id=document.id,
+            action=job.action,
+            job_id=job.jobId,
+            worker_key=worker_key,
+            lease_seconds=lease_seconds,
+            redis_client=redis_client)
+
+    def list_index_jobs(
+        self,
+        *,
+        knowledge_base_id: str | None = None,
+        document_id: str | None = None,
+        status: str | None = None,
+    ) -> list[KnowledgeIndexJobData]:
+        documents = self.document_repository.list_filtered(
+            knowledge_base_id=knowledge_base_id)
+        jobs: list[KnowledgeIndexJobData] = []
+        for document in documents:
+            if document_id is not None and document.id != document_id:
+                continue
+            job = self._try_read_latest_index_job(document=document)
+            if job is None:
+                continue
+            if status is not None and job.status != status:
+                continue
+            jobs.append(job)
+        jobs.sort(
+            key=lambda item: item.queuedTime or datetime.min,
+            reverse=True)
+        return jobs
+
+    def detail_index_job(self, *, document_id: str) -> KnowledgeIndexJobData | None:
+        document = self.document_repository.get_by_id(document_id=document_id)
+        if document is None:
+            return None
+        return self._try_read_latest_index_job(document=document)
+
+    # ── Core indexing logic ───────────────────────────────────────────
+
+    def _index_document(
+        self,
+        *,
+        document: KnowledgeDocument,
+        content_text: str,
+        chunk_size: int | None,
+        chunk_overlap: int | None,
+    ) -> list[KnowledgeChunk]:
+        source_type = document.source_type or "text"
+        resolved_size = chunk_size or self.settings.default_chunk_size
+        resolved_overlap = chunk_overlap or self.settings.default_chunk_overlap
+        chunk_payloads = chunk_document(
+            content_text=content_text,
+            source_type=source_type,
+            chunk_size=resolved_size,
+            chunk_overlap=resolved_overlap)
+        for chunk_payload in chunk_payloads:
+            text = chunk_payload.get("content_text")
+            content = text if isinstance(text, str) else ""
+            embedding_result = self.embedding_service.embed_text(content)
+            chunk_payload["embedding_model"] = embedding_result.model
+            chunk_payload["embedding_json"] = embedding_result.embedding
+            chunk_payload["metadata_json"] = {
+                "embedding_provider": embedding_result.provider,
+            }
+        return self.chunk_repository.replace_document_chunks(
+            knowledge_base_id=document.knowledge_base_id,
+            document_id=document.id,
+            chunks=chunk_payloads)
+
+    def _parse_document_for_indexing(self, *, document: KnowledgeDocument):
+        metadata = document.metadata_json or {}
+        object_metadata = metadata.get("object_storage")
+        if isinstance(object_metadata, dict):
+            object_key = object_metadata.get("objectKey")
+            if isinstance(object_key, str) and object_key:
+                try:
+                    raw_content = self.object_storage.get_bytes(object_key=object_key)
+                except ObjectStorageNotFoundError as exc:
+                    raise ValueError(f"knowledge document content object not found: {document.id}") from exc
+                return parse_document_content(
+                    source_type=document.source_type,
+                    source_uri=document.source_uri,
+                    content_base64=base64.b64encode(raw_content).decode("ascii"))
+        if document.content_text:
+            return parse_document_content(
+                source_type=document.source_type,
+                source_uri=document.source_uri,
+                content_text=document.content_text)
+        raise ValueError(f"knowledge document content object not found: {document.id}")
+
+    # ── Queue helpers ─────────────────────────────────────────────────
+
+    def _should_queue_indexing(self, *, async_mode: bool | None) -> bool:
+        if async_mode is False:
+            return False
+        if not self.settings.async_indexing_enabled and async_mode is None:
+            return False
+        return self.task_queue_publisher is not None
+
+    def _publish_document_index_job(
+        self,
+        *,
+        document_id: str,
+        action: str,
+        job_id: str,
+    ) -> bool:
+        if self.task_queue_publisher is None:
+            return False
+        return self.task_queue_publisher.publish_knowledge_document(
+            document_id=document_id,
+            action=action,
+            job_id=job_id)
+
+    # ── Job metadata ──────────────────────────────────────────────────
+
+    def _write_index_job_metadata(
+        self,
+        *,
+        document: KnowledgeDocument,
+        action: str,
+        job_id: str,
+        status: str,
+        progress: int,
+        chunk_size: int | None = None,
+        chunk_overlap: int | None = None,
+        worker_key: str | None = None,
+        started_time: datetime | None = None,
+        completed_time: datetime | None = None,
+        error_message: str | None = None,
+    ) -> dict[str, JSONValue]:
+        metadata = dict(document.metadata_json or {})
+        existing_job = self._read_index_job_payload(document=document)
+        queued_time = existing_job.get("queuedTime")
+        if not isinstance(queued_time, str):
+            queued_time = datetime.utcnow().isoformat()
+        resolved_started_time = (
+            started_time.isoformat()
+            if started_time is not None
+            else existing_job.get("startedTime")
+        )
+        resolved_completed_time = (
+            completed_time.isoformat()
+            if completed_time is not None
+            else existing_job.get("completedTime")
+        )
+        job_payload: dict[str, JSONValue] = {
+            "jobId": job_id,
+            "documentId": document.id,
+            "knowledgeBaseId": document.knowledge_base_id,
+            "documentTitle": document.title,
+            "action": action if action in {"index", "reindex"} else "reindex",
+            "status": status,
+            "progress": max(0, min(progress, 100)),
+            "queueName": KNOWLEDGE_DOCUMENT_QUEUE,
+            "workerKey": worker_key,
+            "errorMessage": error_message,
+            "chunkSize": chunk_size,
+            "chunkOverlap": chunk_overlap,
+            "queuedTime": queued_time,
+            "startedTime": resolved_started_time if isinstance(resolved_started_time, str) else None,
+            "completedTime": resolved_completed_time if isinstance(resolved_completed_time, str) else None,
+        }
+        metadata["index_job"] = job_payload
+        return metadata
+
+    def _read_latest_index_job(self, *, document: KnowledgeDocument) -> KnowledgeIndexJobData:
+        payload = self._read_index_job_payload(document=document)
+        return KnowledgeIndexJobData(
+            jobId=self._read_payload_string(payload, "jobId") or f"kjob_{document.id}",
+            documentId=self._read_payload_string(payload, "documentId") or document.id,
+            knowledgeBaseId=self._read_payload_string(payload, "knowledgeBaseId") or document.knowledge_base_id,
+            documentTitle=self._read_payload_string(payload, "documentTitle") or document.title,
+            action=self._read_job_action(payload.get("action")),
+            status=self._read_job_status(payload.get("status")),
+            progress=self._read_payload_int(payload, "progress", 0),
+            queueName=self._read_payload_string(payload, "queueName"),
+            workerKey=self._read_payload_string(payload, "workerKey"),
+            errorMessage=self._read_payload_string(payload, "errorMessage"),
+            chunkSize=self._read_optional_payload_int(payload, "chunkSize"),
+            chunkOverlap=self._read_optional_payload_int(payload, "chunkOverlap"),
+            queuedTime=self._read_payload_datetime(payload, "queuedTime"),
+            startedTime=self._read_payload_datetime(payload, "startedTime"),
+            completedTime=self._read_payload_datetime(payload, "completedTime"))
+
+    def _try_read_latest_index_job(self, *, document: KnowledgeDocument) -> KnowledgeIndexJobData | None:
+        if not self._read_index_job_payload(document=document):
+            return None
+        return self._read_latest_index_job(document=document)
+
+    def _read_index_job_payload(self, *, document: KnowledgeDocument) -> dict[str, JSONValue]:
+        metadata = document.metadata_json or {}
+        value = metadata.get("index_job")
+        if isinstance(value, dict):
+            return {str(k): v for k, v in value.items()}
+        return {}
+
+    def _read_payload_string(self, payload: dict[str, JSONValue], key: str) -> str | None:
+        value = payload.get(key)
+        return value if isinstance(value, str) and value else None
+
+    def _read_payload_int(
+        self,
+        payload: dict[str, JSONValue],
+        key: str,
+        fallback: int,
+    ) -> int:
+        value = payload.get(key)
+        if isinstance(value, int) and not isinstance(value, bool):
+            return value
+        return fallback
+
+    def _read_optional_payload_int(
+        self,
+        payload: dict[str, JSONValue],
+        key: str,
+    ) -> int | None:
+        value = payload.get(key)
+        if isinstance(value, int) and not isinstance(value, bool):
+            return value
+        return None
+
+    def _read_payload_datetime(
+        self,
+        payload: dict[str, JSONValue],
+        key: str,
+    ) -> datetime | None:
+        value = payload.get(key)
+        if not isinstance(value, str) or not value:
+            return None
+        try:
+            return datetime.fromisoformat(value)
+        except ValueError:
+            return None
+
+    def _read_job_action(self, value: JSONValue) -> KnowledgeIndexJobAction:
+        if isinstance(value, str) and value in {"index", "reindex"}:
+            return cast(KnowledgeIndexJobAction, value)
+        return "reindex"
+
+    def _read_job_status(self, value: JSONValue) -> KnowledgeIndexJobStatus:
+        if isinstance(value, str) and value in {"queued", "running", "completed", "failed", "skipped"}:
+            return cast(KnowledgeIndexJobStatus, value)
+        return "queued"
+
+    # ── Error handling ────────────────────────────────────────────────
+
+    def _mark_document_failed(
+        self,
+        *,
+        document: KnowledgeDocument,
+        message: str,
+        job_id: str | None = None,
+        action: str = "reindex",
+        worker_key: str | None = None,
+        chunk_size: int | None = None,
+        chunk_overlap: int | None = None,
+    ) -> None:
+        metadata = dict(document.metadata_json or {})
+        metadata["last_error"] = {
+            "message": message[:1000],
+            "errorType": "indexing_failed",
+        }
+        if job_id is not None:
+            metadata = self._write_index_job_metadata(
+                document=document,
+                action=action,
+                job_id=job_id,
+                status="failed",
+                progress=100,
+                chunk_size=chunk_size,
+                chunk_overlap=chunk_overlap,
+                worker_key=worker_key,
+                completed_time=datetime.utcnow(),
+                error_message=message[:1000])
+        self.document_repository.update(
+            document_id=document.id,
+            status="failed",
+            metadata_json=metadata)

+ 158 - 0
services/knowledge-service/app/application/search_service.py

@@ -0,0 +1,158 @@
+"""Knowledge search orchestration sub-service."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from core_shared import JSONValue
+
+from app.application.embeddings import EmbeddingService
+from app.application.retrieval import (
+    bm25_score,
+    compute_bm25_stats,
+    cosine_similarity,
+    rerank_score,
+)
+from app.bootstrap.settings import KnowledgeServiceSettings
+from app.db.models import KnowledgeChunk, KnowledgeDocument
+from app.schemas.knowledge import KnowledgeSearchRequest
+
+if TYPE_CHECKING:
+    from app.domain.repositories import (
+        KnowledgeBaseRepository,
+        KnowledgeChunkRepository,
+        KnowledgeDocumentRepository,
+    )
+
+
+class KnowledgeSearchService:
+    def __init__(
+        self,
+        *,
+        settings: KnowledgeServiceSettings,
+        base_repository: KnowledgeBaseRepository,
+        document_repository: KnowledgeDocumentRepository,
+        chunk_repository: KnowledgeChunkRepository,
+        embedding_service: EmbeddingService,
+    ) -> None:
+        self.settings = settings
+        self.base_repository = base_repository
+        self.document_repository = document_repository
+        self.chunk_repository = chunk_repository
+        self.embedding_service = embedding_service
+
+    def search(
+        self,
+        payload: KnowledgeSearchRequest,
+    ) -> list[tuple[KnowledgeChunk, KnowledgeDocument, float, dict[str, JSONValue]]]:
+        document_cache: dict[str, KnowledgeDocument] = {}
+        query_embedding_result = self.embedding_service.embed_text(payload.query)
+        candidate_limit = max(
+            payload.top_k * max(self.settings.retrieval_candidate_multiplier, 1),
+            payload.top_k,
+        )
+        vector_candidates = self.chunk_repository.search_by_vector(
+            knowledge_base_id=payload.knowledge_base_id,
+            embedding=query_embedding_result.embedding,
+            limit=candidate_limit,
+        )
+        if vector_candidates:
+            chunks = [chunk for chunk, _ in vector_candidates]
+            vector_scores_by_chunk_id = {
+                chunk.id: score for chunk, score in vector_candidates
+            }
+            retrieval_mode = "pgvector-hybrid"
+        else:
+            chunks = self.chunk_repository.list_by_base(
+                knowledge_base_id=payload.knowledge_base_id,
+            )
+            vector_scores_by_chunk_id = {}
+            retrieval_mode = "hybrid"
+
+        kb = self.base_repository.get_by_id(knowledge_base_id=payload.knowledge_base_id)
+        retrieval_config = (kb.metadata_json or {}).get("retrieval_config", {}) if kb else {}
+        keyword_weight = float(retrieval_config.get("keyword_weight", self.settings.retrieval_keyword_weight))
+        vector_weight = float(retrieval_config.get("vector_weight", self.settings.retrieval_vector_weight))
+        rerank_weight = float(retrieval_config.get("rerank_weight", self.settings.retrieval_rerank_weight))
+
+        chunk_texts = [chunk.content_text for chunk in chunks]
+        avg_doc_length, doc_count, df_map = compute_bm25_stats(chunk_texts)
+
+        scored: list[tuple[KnowledgeChunk, KnowledgeDocument, float, dict[str, JSONValue]]] = []
+        for chunk in chunks:
+            document = document_cache.get(chunk.document_id)
+            if document is None:
+                document = self.document_repository.get_by_id(document_id=chunk.document_id)
+                if document is None:
+                    continue
+                document_cache[chunk.document_id] = document
+            if not self._matches_filters(document=document, filters_json=payload.filters_json):
+                continue
+
+            keyword = bm25_score(
+                payload.query, chunk.content_text,
+                avg_doc_length=avg_doc_length, doc_count=doc_count, df=df_map,
+            )
+            vector = vector_scores_by_chunk_id.get(chunk.id)
+            if vector is None:
+                vector = cosine_similarity(query_embedding_result.embedding, chunk.embedding_json)
+            rerank = (
+                rerank_score(
+                    query=payload.query,
+                    chunk_text=chunk.content_text,
+                    document_title=document.title,
+                )
+                if self.settings.retrieval_rerank_enabled
+                else 0.0
+            )
+            score = round(
+                keyword * keyword_weight
+                + vector * vector_weight
+                + rerank * rerank_weight,
+                6,
+            )
+            scored.append((
+                chunk,
+                document,
+                score,
+                {
+                    "final_score": score,
+                    "keyword_score": round(keyword, 6),
+                    "vector_score": round(vector, 6),
+                    "rerank_score": round(rerank, 6),
+                    "retrieval_mode": retrieval_mode,
+                    "rerank_enabled": self.settings.retrieval_rerank_enabled,
+                    "candidate_limit": candidate_limit,
+                    "weights": {
+                        "keyword": keyword_weight,
+                        "vector": vector_weight,
+                        "rerank": rerank_weight,
+                    },
+                    "embedding_provider": query_embedding_result.provider,
+                    "embedding_model": query_embedding_result.model,
+                    "citation": {
+                        "document_id": document.id,
+                        "document_title": document.title,
+                        "source_uri": document.source_uri,
+                        "chunk_id": chunk.id,
+                        "chunk_index": chunk.chunk_index,
+                    },
+                },
+            ))
+
+        scored.sort(key=lambda item: item[2], reverse=True)
+        return scored[: payload.top_k]
+
+    @staticmethod
+    def _matches_filters(
+        *,
+        document: KnowledgeDocument,
+        filters_json: dict[str, JSONValue],
+    ) -> bool:
+        source_type = filters_json.get("sourceType") or filters_json.get("source_type")
+        if isinstance(source_type, str) and document.source_type != source_type:
+            return False
+        status = filters_json.get("status")
+        if isinstance(status, str) and document.status != status:
+            return False
+        return True

+ 145 - 1128
services/knowledge-service/app/application/services.py

@@ -1,83 +1,54 @@
-from __future__ import annotations
+"""Knowledge application service — thin facade delegating to sub-services."""
 
 
-import base64
-import hashlib
-from dataclasses import dataclass
-from datetime import datetime, timedelta
-from typing import TYPE_CHECKING, cast
-from uuid import uuid4
+from __future__ import annotations
 
 
-from sqlalchemy.orm import Session
+from typing import TYPE_CHECKING
 
 
 from core_shared import JSONValue, try_build_redis_client
 from core_shared import JSONValue, try_build_redis_client
-from core_shared.task_queue import KNOWLEDGE_DOCUMENT_QUEUE, TaskQueuePublisher
+from core_shared.task_queue import TaskQueuePublisher
 
 
-from app.application.document_parsers import (
-    DocumentParseError,
-    ParsedDocument,
-    normalize_source_type,
-    parse_document_content,
-    read_document_content_bytes)
-from app.application.chunking import chunk_document
+from app.application.crud_service import KnowledgeCrudService
 from app.application.embeddings import EmbeddingService
 from app.application.embeddings import EmbeddingService
-from app.application.retrieval import (
-    bm25_score,
-    build_chunk_payloads,
-    compute_bm25_stats,
-    cosine_similarity,
-    keyword_score,
-    rerank_score,
-    stable_content_hash)
+from app.application.indexing_service import (
+    KnowledgeDocumentIngestResult,
+    KnowledgeIndexingError,
+    KnowledgeIndexingService,
+)
+from app.application.search_service import KnowledgeSearchService
+from app.application.settings_service import KnowledgeSettingsService
 from app.bootstrap.settings import KnowledgeServiceSettings
 from app.bootstrap.settings import KnowledgeServiceSettings
 from app.db.models import KnowledgeBase, KnowledgeChunk, KnowledgeDocument
 from app.db.models import KnowledgeBase, KnowledgeChunk, KnowledgeDocument
-from app.domain.repositories import (
-    KnowledgeBaseRepository,
-    KnowledgeChunkRepository,
-    KnowledgeDocumentRepository)
-from app.infrastructure.object_storage import (
-    KnowledgeObjectStorage,
-    ObjectStorageError,
-    ObjectStorageNotFoundError,
-    ObjectStorageStatus,
-    build_document_object_key,
-    build_object_storage)
 from app.schemas.knowledge import (
 from app.schemas.knowledge import (
     KnowledgeBaseCreateRequest,
     KnowledgeBaseCreateRequest,
     KnowledgeBaseCreateRequestDto,
     KnowledgeBaseCreateRequestDto,
+    KnowledgeBaseReindexRequestDto,
     KnowledgeBaseStatusUpdateRequest,
     KnowledgeBaseStatusUpdateRequest,
     KnowledgeBaseUpdateRequestDto,
     KnowledgeBaseUpdateRequestDto,
-    KnowledgeBaseReindexRequestDto,
     KnowledgeDocumentCreateRequest,
     KnowledgeDocumentCreateRequest,
     KnowledgeDocumentCreateRequestDto,
     KnowledgeDocumentCreateRequestDto,
-    KnowledgeIndexJobAction,
-    KnowledgeIndexJobData,
-    KnowledgeIndexJobStatus,
     KnowledgeDocumentParseRequest,
     KnowledgeDocumentParseRequest,
     KnowledgeDocumentReindexRequestDto,
     KnowledgeDocumentReindexRequestDto,
     KnowledgeDocumentUpdateRequestDto,
     KnowledgeDocumentUpdateRequestDto,
+    KnowledgeIndexJobData,
+    KnowledgeSearchRequest,
     KnowledgeSettingsDto,
     KnowledgeSettingsDto,
     KnowledgeSettingsUpdateRequestDto,
     KnowledgeSettingsUpdateRequestDto,
-    KnowledgeSearchRequest)
+)
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from redis import Redis
     from redis import Redis
 
 
-
-@dataclass(frozen=True, slots=True)
-class KnowledgeDocumentIngestResult:
-    document: KnowledgeDocument
-    chunks: list[KnowledgeChunk]
-    queued: bool = False
-    job: KnowledgeIndexJobData | None = None
-
-
-class KnowledgeIndexingError(RuntimeError):
-    def __init__(self, *, document_id: str, message: str) -> None:
-        super().__init__(message)
-        self.document_id = document_id
+    from app.domain.repositories import (
+        KnowledgeBaseRepository,
+        KnowledgeChunkRepository,
+        KnowledgeDocumentRepository,
+    )
+    from app.infrastructure.object_storage import KnowledgeObjectStorage
 
 
 
 
 class KnowledgeApplicationService:
 class KnowledgeApplicationService:
+    """Facade composing CRUD, Indexing, Search, and Settings sub-services."""
+
     def __init__(
     def __init__(
         self,
         self,
         *,
         *,
@@ -87,7 +58,8 @@ class KnowledgeApplicationService:
         chunk_repository: KnowledgeChunkRepository,
         chunk_repository: KnowledgeChunkRepository,
         object_storage: KnowledgeObjectStorage | None = None,
         object_storage: KnowledgeObjectStorage | None = None,
         redis_client: Redis | None = None,
         redis_client: Redis | None = None,
-        task_queue_publisher: TaskQueuePublisher | None = None) -> None:
+        task_queue_publisher: TaskQueuePublisher | None = None,
+    ) -> None:
         self.settings = settings
         self.settings = settings
         self.base_repository = base_repository
         self.base_repository = base_repository
         self.document_repository = document_repository
         self.document_repository = document_repository
@@ -97,1134 +69,179 @@ class KnowledgeApplicationService:
         self.redis_client = redis_client
         self.redis_client = redis_client
         self.task_queue_publisher = task_queue_publisher
         self.task_queue_publisher = task_queue_publisher
 
 
+        self.crud_service = KnowledgeCrudService(
+            settings=settings,
+            base_repository=base_repository,
+            document_repository=document_repository,
+            chunk_repository=chunk_repository,
+            object_storage=object_storage,
+        )
+        self.indexing_service = KnowledgeIndexingService(
+            settings=settings,
+            base_repository=base_repository,
+            document_repository=document_repository,
+            chunk_repository=chunk_repository,
+            embedding_service=self.embedding_service,
+            object_storage=object_storage,
+            redis_client=redis_client,
+            task_queue_publisher=task_queue_publisher,
+        )
+        self.search_service = KnowledgeSearchService(
+            settings=settings,
+            base_repository=base_repository,
+            document_repository=document_repository,
+            chunk_repository=chunk_repository,
+            embedding_service=self.embedding_service,
+        )
+        self.settings_service = KnowledgeSettingsService(
+            settings=settings,
+            base_repository=base_repository,
+        )
+
     @property
     @property
     def object_storage(self) -> KnowledgeObjectStorage:
     def object_storage(self) -> KnowledgeObjectStorage:
-        if self._object_storage is None:
-            self._object_storage = build_object_storage(self.settings)
-        return self._object_storage
+        return self.crud_service.object_storage
+
+    # ── Knowledge Base ────────────────────────────────────────────────
 
 
     def create_base(self, payload: KnowledgeBaseCreateRequest) -> KnowledgeBase:
     def create_base(self, payload: KnowledgeBaseCreateRequest) -> KnowledgeBase:
-        return self.base_repository.create(
-            code=payload.code,
-            name=payload.name,
-            description=payload.description,
-            metadata_json=payload.metadata_json)
+        return self.crud_service.create_base(payload)
 
 
-    def create_base_from_contract(
-        self,
-        payload: KnowledgeBaseCreateRequestDto) -> KnowledgeBase:
-        return self.create_base(
-            KnowledgeBaseCreateRequest(
-                code=self._build_base_code(payload.name),
-                name=payload.name,
-                description=payload.description,
-                metadata_json=payload.metadata))
+    def create_base_from_contract(self, payload: KnowledgeBaseCreateRequestDto) -> KnowledgeBase:
+        return self.crud_service.create_base_from_contract(payload)
 
 
     def list_bases(self) -> list[KnowledgeBase]:
     def list_bases(self) -> list[KnowledgeBase]:
-        return self.base_repository.list_all()
+        return self.crud_service.list_bases()
 
 
-    def list_bases_filtered(
-        self,
-        *,
-        keyword: str | None = None,
-        status: str | None = None) -> list[KnowledgeBase]:
-        return self.base_repository.list_filtered(
-            keyword=keyword,
-            status=status)
+    def list_bases_filtered(self, *, keyword: str | None = None, status: str | None = None) -> list[KnowledgeBase]:
+        return self.crud_service.list_bases_filtered(keyword=keyword, status=status)
 
 
-    def update_base_from_contract(
-        self,
-        payload: KnowledgeBaseUpdateRequestDto) -> KnowledgeBase | None:
-        return self.base_repository.update(
-            knowledge_base_id=payload.knowledgeBaseId,
-            name=payload.name,
-            description=payload.description,
-            status=payload.status,
-            metadata_json=payload.metadata)
+    def update_base_from_contract(self, payload: KnowledgeBaseUpdateRequestDto) -> KnowledgeBase | None:
+        return self.crud_service.update_base_from_contract(payload)
 
 
     def delete_base(self, *, knowledge_base_id: str) -> bool:
     def delete_base(self, *, knowledge_base_id: str) -> bool:
-        documents = self.document_repository.list_by_base(
-            knowledge_base_id=knowledge_base_id)
-        for document in documents:
-            self._delete_document_object(document=document)
-        self.chunk_repository.delete_by_base(knowledge_base_id=knowledge_base_id)
-        for document in documents:
-            self.document_repository.delete(document_id=document.id)
-        return self.base_repository.delete(knowledge_base_id=knowledge_base_id)
-
-    def update_base_status(
-        self,
-        *,
-        knowledge_base_id: str,
-        payload: KnowledgeBaseStatusUpdateRequest) -> KnowledgeBase | None:
-        return self.base_repository.update_status(
-            knowledge_base_id=knowledge_base_id,
-            status=payload.status)
-
-    def create_document(
-        self,
-        payload: KnowledgeDocumentCreateRequest) -> tuple[KnowledgeDocument, list[KnowledgeChunk]]:
-        knowledge_base = self.base_repository.get_by_id(
-            knowledge_base_id=payload.knowledge_base_id)
-        if knowledge_base is None:
-            raise ValueError(f"knowledge base not found: {payload.knowledge_base_id}")
-
-        parsed = self.parse_document(
-            KnowledgeDocumentParseRequest(
-                source_type=payload.source_type,
-                source_uri=payload.source_uri,
-                content_text=payload.content_text,
-                content_base64=payload.content_base64)
-        )
-        raw_content = read_document_content_bytes(
-            content_text=payload.content_text,
-            content_base64=payload.content_base64)
-        object_key = build_document_object_key(
-            knowledge_base_id=payload.knowledge_base_id,
-            source_type=parsed.source_type,
-            title=payload.title)
-        stored_object = self.object_storage.put_bytes(
-            object_key=object_key,
-            content=raw_content,
-            content_type=self._guess_content_type(source_type=parsed.source_type))
-        document: KnowledgeDocument | None = None
-        try:
-            metadata_json = {
-                **payload.metadata_json,
-                "parser_metadata": parsed.metadata_json,
-                "object_storage": stored_object.to_metadata(),
-            }
-            document = self.document_repository.create(
-                knowledge_base_id=payload.knowledge_base_id,
-                title=payload.title,
-                source_type=parsed.source_type,
-                source_uri=payload.source_uri,
-                content_text="",
-                content_hash=stable_content_hash(parsed.content_text),
-                metadata_json=metadata_json)
-            try:
-                chunks = self._index_document(
-                    document=document,
-                    content_text=parsed.content_text,
-                    chunk_size=payload.chunk_size,
-                    chunk_overlap=payload.chunk_overlap)
-            except Exception as exc:
-                self._mark_document_failed(document=document, message=str(exc))
-                raise KnowledgeIndexingError(
-                    document_id=document.id,
-                    message=f"knowledge document indexing failed: {exc}") from exc
-            indexed_document = self.document_repository.update_status(
-                document_id=document.id,
-                status="indexed")
-            return indexed_document or document, chunks
-        except Exception:
-            if document is None:
-                try:
-                    self.object_storage.delete_object(object_key=stored_object.object_key)
-                except ObjectStorageError:
-                    pass
-            raise
-
-    def create_document_from_contract(
-        self,
-        payload: KnowledgeDocumentCreateRequestDto) -> tuple[KnowledgeDocument, list[KnowledgeChunk]]:
-        return self.create_document(
-            KnowledgeDocumentCreateRequest(
-                knowledge_base_id=payload.knowledgeBaseId,
-                title=payload.title,
-                content_text=payload.contentText,
-                content_base64=payload.contentBase64,
-                source_type=payload.sourceType,
-                source_uri=payload.sourceUri,
-                metadata_json=payload.metadata,
-                chunk_size=payload.chunkSize,
-                chunk_overlap=payload.chunkOverlap))
-
-    def create_document_from_contract_result(
-        self,
-        payload: KnowledgeDocumentCreateRequestDto) -> KnowledgeDocumentIngestResult:
-        request = KnowledgeDocumentCreateRequest(
-            knowledge_base_id=payload.knowledgeBaseId,
-            title=payload.title,
-            content_text=payload.contentText,
-            content_base64=payload.contentBase64,
-            source_type=payload.sourceType,
-            source_uri=payload.sourceUri,
-            metadata_json=payload.metadata,
-            chunk_size=payload.chunkSize,
-            chunk_overlap=payload.chunkOverlap)
-        if self._should_queue_indexing(async_mode=payload.asyncMode):
-            return self.create_document_index_job(payload=request)
-        document, chunks = self.create_document(request)
-        return KnowledgeDocumentIngestResult(
-            document=document,
-            chunks=chunks)
-
-    def create_document_index_job(
-        self,
-        payload: KnowledgeDocumentCreateRequest) -> KnowledgeDocumentIngestResult:
-        knowledge_base = self.base_repository.get_by_id(
-            knowledge_base_id=payload.knowledge_base_id)
-        if knowledge_base is None:
-            raise ValueError(f"knowledge base not found: {payload.knowledge_base_id}")
-
-        raw_content = read_document_content_bytes(
-            content_text=payload.content_text,
-            content_base64=payload.content_base64)
-        source_type = normalize_source_type(
-            source_type=payload.source_type,
-            source_uri=payload.source_uri)
-        object_key = build_document_object_key(
-            knowledge_base_id=payload.knowledge_base_id,
-            source_type=source_type,
-            title=payload.title)
-        stored_object = self.object_storage.put_bytes(
-            object_key=object_key,
-            content=raw_content,
-            content_type=self._guess_content_type(source_type=source_type))
-        document: KnowledgeDocument | None = None
-        try:
-            document = self.document_repository.create(
-                knowledge_base_id=payload.knowledge_base_id,
-                title=payload.title,
-                source_type=source_type,
-                source_uri=payload.source_uri,
-                content_text="",
-                content_hash=hashlib.sha256(raw_content).hexdigest(),
-                metadata_json={
-                    **payload.metadata_json,
-                    "object_storage": stored_object.to_metadata(),
-                },
-                status="draft")
-            queued_document, job = self.queue_document_indexing(
-                document=document,
-                action="index",
-                chunk_size=payload.chunk_size,
-                chunk_overlap=payload.chunk_overlap)
-        except Exception:
-            if document is None:
-                try:
-                    self.object_storage.delete_object(object_key=stored_object.object_key)
-                except ObjectStorageError:
-                    pass
-            raise
-        return KnowledgeDocumentIngestResult(
-            document=queued_document,
-            chunks=[],
-            queued=True,
-            job=job)
-
-    def parse_document(self, payload: KnowledgeDocumentParseRequest) -> ParsedDocument:
-        try:
-            return parse_document_content(
-                source_type=payload.source_type,
-                content_text=payload.content_text,
-                content_base64=payload.content_base64,
-                source_uri=payload.source_uri)
-        except DocumentParseError:
-            raise
-
-    def queue_document_indexing(
-        self,
-        *,
-        document: KnowledgeDocument,
-        action: str,
-        chunk_size: int | None = None,
-        chunk_overlap: int | None = None) -> tuple[KnowledgeDocument, KnowledgeIndexJobData]:
-        job_id = f"kjob_{uuid4().hex}"
-        metadata = self._write_index_job_metadata(
-            document=document,
-            action=action,
-            job_id=job_id,
-            status="queued",
-            progress=0,
-            chunk_size=chunk_size,
-            chunk_overlap=chunk_overlap)
-        updated_document = self.document_repository.update(
-            document_id=document.id,
-            status="queued",
-            metadata_json=metadata)
-        document_for_job = updated_document or document
-        published = self._publish_document_index_job(
-            document_id=document.id,
-            action=action,
-            job_id=job_id)
-        if not published:
-            metadata = self._write_index_job_metadata(
-                document=document_for_job,
-                action=action,
-                job_id=job_id,
-                status="running",
-                progress=1,
-                chunk_size=chunk_size,
-                chunk_overlap=chunk_overlap,
-                worker_key="inline-fallback")
-            document_for_job = self.document_repository.update(
-                document_id=document.id,
-                status="indexing",
-                metadata_json=metadata) or document_for_job
-            processed = self.process_document_index_job(
-                document_id=document.id,
-                action=action,
-                job_id=job_id,
-                worker_key="inline-fallback",
-                chunk_size=chunk_size,
-                chunk_overlap=chunk_overlap)
-            if processed is not None:
-                indexed_document, chunks = processed
-                return indexed_document, self._read_latest_index_job(document=indexed_document)
-        return document_for_job, self._read_latest_index_job(document=document_for_job)
-
-    def list_documents(
-        self,
-        *,
-        knowledge_base_id: str) -> list[KnowledgeDocument]:
-        return self.document_repository.list_by_base(
-            knowledge_base_id=knowledge_base_id)
+        return self.crud_service.delete_base(knowledge_base_id=knowledge_base_id)
 
 
-    def list_documents_filtered(
-        self,
-        *,
-        knowledge_base_id: str | None = None,
-        keyword: str | None = None,
-        status: str | None = None,
-        source_type: str | None = None) -> list[KnowledgeDocument]:
-        return self.document_repository.list_filtered(
-            knowledge_base_id=knowledge_base_id,
-            keyword=keyword,
-            status=status,
-            source_type=source_type)
+    def update_base_status(self, *, knowledge_base_id: str, payload: KnowledgeBaseStatusUpdateRequest) -> KnowledgeBase | None:
+        return self.crud_service.update_base_status(knowledge_base_id=knowledge_base_id, payload=payload)
 
 
-    def update_document_from_contract(
-        self,
-        payload: KnowledgeDocumentUpdateRequestDto) -> KnowledgeDocument | None:
-        return self.document_repository.update(
-            document_id=payload.documentId,
-            title=payload.title,
-            source_uri=payload.sourceUri,
-            status=payload.status,
-            metadata_json=payload.metadata)
-
-    def reindex_document(
-        self,
-        payload: KnowledgeDocumentReindexRequestDto) -> tuple[KnowledgeDocument, list[KnowledgeChunk]] | None:
-        document = self.document_repository.get_by_id(document_id=payload.documentId)
-        if document is None:
-            return None
-        try:
-            parsed = self._parse_document_for_indexing(document=document)
-            chunks = self._index_document(
-                document=document,
-                content_text=parsed.content_text,
-                chunk_size=payload.chunkSize,
-                chunk_overlap=payload.chunkOverlap)
-        except Exception as exc:
-            self._mark_document_failed(document=document, message=str(exc))
-            raise KnowledgeIndexingError(
-                document_id=document.id,
-                message=f"knowledge document reindex failed: {exc}") from exc
-        metadata = dict(document.metadata_json or {})
-        metadata["parser_metadata"] = parsed.metadata_json
-        indexed_document = self.document_repository.update(
-            document_id=document.id,
-            status="indexed",
-            metadata_json=metadata)
-        return indexed_document or document, chunks
+    # ── Document CRUD ─────────────────────────────────────────────────
 
 
-    def reindex_document_from_contract_result(
-        self,
-        payload: KnowledgeDocumentReindexRequestDto) -> KnowledgeDocumentIngestResult | None:
-        if self._should_queue_indexing(async_mode=payload.asyncMode):
-            document = self.document_repository.get_by_id(document_id=payload.documentId)
-            if document is None:
-                return None
-            queued_document, job = self.queue_document_indexing(
-                document=document,
-                action="reindex",
-                chunk_size=payload.chunkSize,
-                chunk_overlap=payload.chunkOverlap)
-            return KnowledgeDocumentIngestResult(
-                document=queued_document,
-                chunks=[],
-                queued=True,
-                job=job)
-        result = self.reindex_document(payload)
-        if result is None:
-            return None
-        document, chunks = result
-        return KnowledgeDocumentIngestResult(
-            document=document,
-            chunks=chunks)
-
-    def reindex_base_from_contract(
-        self,
-        payload: KnowledgeBaseReindexRequestDto) -> list[KnowledgeIndexJobData]:
-        documents = self.document_repository.list_by_base(
-            knowledge_base_id=payload.knowledgeBaseId)
-        jobs: list[KnowledgeIndexJobData] = []
-        for document in documents:
-            if document.status == "archived":
-                continue
-            queued_document, job = self.queue_document_indexing(
-                document=document,
-                action="reindex",
-                chunk_size=payload.chunkSize,
-                chunk_overlap=payload.chunkOverlap)
-            jobs.append(job or self._read_latest_index_job(document=queued_document))
-        return jobs
-
-    def process_document_index_job(
-        self,
-        *,
-        document_id: str,
-        action: str,
-        worker_key: str,
-        job_id: str | None = None,
-        chunk_size: int | None = None,
-        chunk_overlap: int | None = None) -> tuple[KnowledgeDocument, list[KnowledgeChunk]] | None:
-        document = self.document_repository.get_by_id(document_id=document_id)
-        if document is None:
-            return None
-        resolved_job = self._read_latest_index_job(document=document)
-        resolved_job_id = job_id or resolved_job.jobId
-        resolved_chunk_size = chunk_size if chunk_size is not None else resolved_job.chunkSize
-        resolved_chunk_overlap = chunk_overlap if chunk_overlap is not None else resolved_job.chunkOverlap
-        if document.status == "archived":
-            metadata = self._write_index_job_metadata(
-                document=document,
-                action=action,
-                job_id=resolved_job_id,
-                status="skipped",
-                progress=100,
-                chunk_size=resolved_chunk_size,
-                chunk_overlap=resolved_chunk_overlap,
-                worker_key=worker_key,
-                completed_time=datetime.utcnow(),
-                error_message="document is archived")
-            skipped_document = self.document_repository.update(
-                document_id=document.id,
-                metadata_json=metadata) or document
-            return skipped_document, []
+    def list_documents(self, *, knowledge_base_id: str) -> list[KnowledgeDocument]:
+        return self.crud_service.list_documents(knowledge_base_id=knowledge_base_id)
 
 
-        metadata = self._write_index_job_metadata(
-            document=document,
-            action=action,
-            job_id=resolved_job_id,
-            status="running",
-            progress=10,
-            chunk_size=resolved_chunk_size,
-            chunk_overlap=resolved_chunk_overlap,
-            worker_key=worker_key,
-            started_time=datetime.utcnow())
-        running_document = self.document_repository.update(
-            document_id=document.id,
-            status="indexing",
-            metadata_json=metadata) or document
-        try:
-            parsed = self._parse_document_for_indexing(document=running_document)
-            metadata = self._write_index_job_metadata(
-                document=running_document,
-                action=action,
-                job_id=resolved_job_id,
-                status="running",
-                progress=40,
-                chunk_size=resolved_chunk_size,
-                chunk_overlap=resolved_chunk_overlap,
-                worker_key=worker_key,
-                started_time=self._read_latest_index_job(document=running_document).startedTime)
-            metadata["parser_metadata"] = parsed.metadata_json
-            running_document = self.document_repository.update(
-                document_id=document.id,
-                status="indexing",
-                metadata_json=metadata) or running_document
-            chunks = self._index_document(
-                document=running_document,
-                content_text=parsed.content_text,
-                chunk_size=resolved_chunk_size,
-                chunk_overlap=resolved_chunk_overlap)
-        except Exception as exc:
-            self._mark_document_failed(
-                document=running_document,
-                message=str(exc),
-                job_id=resolved_job_id,
-                action=action,
-                worker_key=worker_key,
-                chunk_size=resolved_chunk_size,
-                chunk_overlap=resolved_chunk_overlap)
-            raise KnowledgeIndexingError(
-                document_id=document.id,
-                message=f"knowledge document {action} failed: {exc}") from exc
+    def list_documents_filtered(self, *, knowledge_base_id: str | None = None, keyword: str | None = None, status: str | None = None, source_type: str | None = None) -> list[KnowledgeDocument]:
+        return self.crud_service.list_documents_filtered(
+            knowledge_base_id=knowledge_base_id, keyword=keyword,
+            status=status, source_type=source_type)
 
 
-        metadata = self._write_index_job_metadata(
-            document=running_document,
-            action=action,
-            job_id=resolved_job_id,
-            status="completed",
-            progress=100,
-            chunk_size=resolved_chunk_size,
-            chunk_overlap=resolved_chunk_overlap,
-            worker_key=worker_key,
-            completed_time=datetime.utcnow())
-        metadata["parser_metadata"] = parsed.metadata_json
-        indexed_document = self.document_repository.update(
-            document_id=document.id,
-            status="indexed",
-            metadata_json=metadata)
-        return indexed_document or running_document, chunks
-
-    def execute_document_index_job(
-        self,
-        *,
-        document_id: str,
-        action: str,
-        worker_key: str,
-        lease_seconds: int,
-        job_id: str | None = None,
-        redis_client: Redis | None = None) -> tuple[KnowledgeDocument, list[KnowledgeChunk]] | None:
-        resolved_redis_client = redis_client or self.redis_client
-        idempotency_key = f"{document_id}:{job_id or action}"
-        lock = None
-        idempotency_store = None
-        if resolved_redis_client is not None:
-            from core_shared.redis_primitives import DistributedLock, IdempotencyStore
-
-            lock = DistributedLock(
-                client=resolved_redis_client,
-                name=f"knowledge-document:{document_id}:lock",
-                ttl_seconds=lease_seconds)
-            if not lock.acquire():
-                return None
-            idempotency_store = IdempotencyStore(
-                client=resolved_redis_client,
-                prefix="knowledge-document-idempotency")
-            if not idempotency_store.begin(key=idempotency_key):
-                lock.release()
-                return None
-        try:
-            result = self.process_document_index_job(
-                document_id=document_id,
-                action=action,
-                job_id=job_id,
-                worker_key=worker_key)
-            if idempotency_store is not None and result is not None:
-                document, chunks = result
-                idempotency_store.complete(
-                    key=idempotency_key,
-                    result={
-                        "status": document.status,
-                        "document_id": document.id,
-                        "chunk_count": len(chunks),
-                    })
-        except Exception:
-            if idempotency_store is not None:
-                idempotency_store.clear(key=idempotency_key)
-            raise
-        finally:
-            if lock is not None:
-                lock.release()
-        return result
-
-    def execute_next_pending_document_job(
-        self,
-        *,
-        worker_key: str,
-        lease_seconds: int,
-        stale_indexing_seconds: int,
-        redis_client: Redis | None = None) -> tuple[KnowledgeDocument, list[KnowledgeChunk]] | None:
-        stale_before = datetime.utcnow() - timedelta(seconds=stale_indexing_seconds)
-        document = self.document_repository.get_next_pending_indexing(
-            stale_before=stale_before)
-        if document is None:
-            return None
-        job = self._read_latest_index_job(document=document)
-        return self.execute_document_index_job(
-            document_id=document.id,
-            action=job.action,
-            job_id=job.jobId,
-            worker_key=worker_key,
-            lease_seconds=lease_seconds,
-            redis_client=redis_client)
-
-    def list_index_jobs(
-        self,
-        *,
-        knowledge_base_id: str | None = None,
-        document_id: str | None = None,
-        status: str | None = None) -> list[KnowledgeIndexJobData]:
-        documents = self.document_repository.list_filtered(
-            knowledge_base_id=knowledge_base_id)
-        jobs: list[KnowledgeIndexJobData] = []
-        for document in documents:
-            if document_id is not None and document.id != document_id:
-                continue
-            job = self._try_read_latest_index_job(document=document)
-            if job is None:
-                continue
-            if status is not None and job.status != status:
-                continue
-            jobs.append(job)
-        jobs.sort(
-            key=lambda item: item.queuedTime or datetime.min,
-            reverse=True)
-        return jobs
-
-    def detail_index_job(self, *, document_id: str) -> KnowledgeIndexJobData | None:
-        document = self.document_repository.get_by_id(document_id=document_id)
-        if document is None:
-            return None
-        return self._try_read_latest_index_job(document=document)
+    def update_document_from_contract(self, payload: KnowledgeDocumentUpdateRequestDto) -> KnowledgeDocument | None:
+        return self.crud_service.update_document_from_contract(payload)
 
 
     def delete_document(self, *, document_id: str) -> bool:
     def delete_document(self, *, document_id: str) -> bool:
-        return bool(self.delete_document_result(document_id=document_id)["deleted"])
+        return self.crud_service.delete_document(document_id=document_id)
 
 
     def delete_document_result(self, *, document_id: str) -> dict[str, JSONValue]:
     def delete_document_result(self, *, document_id: str) -> dict[str, JSONValue]:
-        document = self.document_repository.get_by_id(document_id=document_id)
-        if document is None:
-            return {
-                "deleted": False,
-                "objectDeleted": False,
-                "documentId": document_id,
-            }
-        object_deleted = self._delete_document_object(document=document)
-        self.chunk_repository.delete_by_document(document_id=document_id)
-        deleted = self.document_repository.delete(document_id=document_id) is not None
-        return {
-            "deleted": deleted,
-            "objectDeleted": object_deleted,
-            "documentId": document_id,
-        }
+        return self.crud_service.delete_document_result(document_id=document_id)
 
 
-    def read_document_content(
-        self,
-        *,
-        document_id: str,
-        include_text: bool = True,
-        include_base64: bool = False) -> dict[str, JSONValue] | None:
-        document = self.document_repository.get_by_id(document_id=document_id)
-        if document is None:
-            return None
-        raw_content = self._read_document_raw_content(document=document)
-        object_status = self.read_document_storage_status(document_id=document_id)
-        content_type = self._read_content_type_from_status(object_status)
-        payload: dict[str, JSONValue] = {
-            "documentId": document.id,
-            "title": document.title,
-            "sourceType": document.source_type,
-            "contentType": content_type,
-            "sizeBytes": len(raw_content),
-            "objectStorage": self._read_object_storage_metadata(document=document),
-            "contentBase64": None,
-            "contentText": None,
-        }
-        if include_base64:
-            payload["contentBase64"] = base64.b64encode(raw_content).decode("ascii")
-        if include_text and self._is_text_content_type(content_type=content_type, source_type=document.source_type):
-            payload["contentText"] = raw_content.decode("utf-8", errors="replace")
-        return payload
+    def read_document_content(self, *, document_id: str, include_text: bool = True, include_base64: bool = False) -> dict[str, JSONValue] | None:
+        return self.crud_service.read_document_content(
+            document_id=document_id, include_text=include_text, include_base64=include_base64)
 
 
     def read_document_storage_status(self, *, document_id: str) -> dict[str, JSONValue] | None:
     def read_document_storage_status(self, *, document_id: str) -> dict[str, JSONValue] | None:
-        document = self.document_repository.get_by_id(document_id=document_id)
-        if document is None:
-            return None
-        object_key = self._read_document_object_key(document=document)
-        if object_key is None:
-            return {
-                "documentId": document.id,
-                "exists": False,
-                "objectStorage": None,
-                "errorMessage": "document object metadata is missing",
-            }
-        status = self.object_storage.head_object(object_key=object_key)
-        return self._object_status_to_payload(document=document, status=status)
+        return self.crud_service.read_document_storage_status(document_id=document_id)
 
 
     def read_storage_health(self) -> dict[str, JSONValue]:
     def read_storage_health(self) -> dict[str, JSONValue]:
-        return dict(self.object_storage.health_check())
-
-    def list_chunks_filtered(
-        self,
-        *,
-        knowledge_base_id: str | None = None,
-        document_id: str | None = None,
-        keyword: str | None = None) -> list[KnowledgeChunk]:
-        return self.chunk_repository.list_filtered(
-            knowledge_base_id=knowledge_base_id,
-            document_id=document_id,
-            keyword=keyword)
-
-    def delete_chunk(self, *, chunk_id: str) -> bool:
-        return self.chunk_repository.delete(chunk_id=chunk_id)
-
-    def search(
-        self,
-        payload: KnowledgeSearchRequest) -> list[tuple[KnowledgeChunk, KnowledgeDocument, float, dict[str, JSONValue]]]:
-        document_cache: dict[str, KnowledgeDocument] = {}
-        query_embedding_result = self.embedding_service.embed_text(payload.query)
-        candidate_limit = max(
-            payload.top_k * max(self.settings.retrieval_candidate_multiplier, 1),
-            payload.top_k)
-        vector_candidates = self.chunk_repository.search_by_vector(
-            knowledge_base_id=payload.knowledge_base_id,
-            embedding=query_embedding_result.embedding,
-            limit=candidate_limit)
-        if vector_candidates:
-            chunks = [chunk for chunk, _ in vector_candidates]
-            vector_scores_by_chunk_id = {
-                chunk.id: score for chunk, score in vector_candidates
-            }
-            retrieval_mode = "pgvector-hybrid"
-        else:
-            chunks = self.chunk_repository.list_by_base(
-                knowledge_base_id=payload.knowledge_base_id)
-            vector_scores_by_chunk_id = {}
-            retrieval_mode = "hybrid"
-        # Resolve per-base retrieval weights with global fallback
-        kb = self.base_repository.get_by_id(knowledge_base_id=payload.knowledge_base_id)
-        retrieval_config = (kb.metadata_json or {}).get("retrieval_config", {}) if kb else {}
-        keyword_weight = float(retrieval_config.get("keyword_weight", self.settings.retrieval_keyword_weight))
-        vector_weight = float(retrieval_config.get("vector_weight", self.settings.retrieval_vector_weight))
-        rerank_weight = float(retrieval_config.get("rerank_weight", self.settings.retrieval_rerank_weight))
-        # Pre-compute BM25 collection stats from candidate chunks
-        chunk_texts = [chunk.content_text for chunk in chunks]
-        avg_doc_length, doc_count, df_map = compute_bm25_stats(chunk_texts)
-        scored: list[tuple[KnowledgeChunk, KnowledgeDocument, float, dict[str, JSONValue]]] = []
-        for chunk in chunks:
-            document = document_cache.get(chunk.document_id)
-            if document is None:
-                document = self.document_repository.get_by_id(
-                    document_id=chunk.document_id)
-                if document is None:
-                    continue
-                document_cache[chunk.document_id] = document
-            if not self._matches_filters(document=document, filters_json=payload.filters_json):
-                continue
-            keyword = bm25_score(
-                payload.query, chunk.content_text,
-                avg_doc_length=avg_doc_length, doc_count=doc_count, df=df_map)
-            vector = vector_scores_by_chunk_id.get(chunk.id)
-            if vector is None:
-                vector = cosine_similarity(query_embedding_result.embedding, chunk.embedding_json)
-            rerank = (
-                rerank_score(
-                    query=payload.query,
-                    chunk_text=chunk.content_text,
-                    document_title=document.title)
-                if self.settings.retrieval_rerank_enabled
-                else 0.0
-            )
-            score = round(
-                keyword * keyword_weight
-                + vector * vector_weight
-                + rerank * rerank_weight,
-                6)
-            scored.append(
-                (
-                    chunk,
-                    document,
-                    score,
-                    {
-                        "final_score": score,
-                        "keyword_score": round(keyword, 6),
-                        "vector_score": round(vector, 6),
-                        "rerank_score": round(rerank, 6),
-                        "retrieval_mode": retrieval_mode,
-                        "rerank_enabled": self.settings.retrieval_rerank_enabled,
-                        "candidate_limit": candidate_limit,
-                        "weights": {
-                            "keyword": keyword_weight,
-                            "vector": vector_weight,
-                            "rerank": rerank_weight,
-                        },
-                        "embedding_provider": query_embedding_result.provider,
-                        "embedding_model": query_embedding_result.model,
-                        "citation": {
-                            "document_id": document.id,
-                            "document_title": document.title,
-                            "source_uri": document.source_uri,
-                            "chunk_id": chunk.id,
-                            "chunk_index": chunk.chunk_index,
-                        },
-                    })
-            )
-        scored.sort(key=lambda item: item[2], reverse=True)
-        return scored[: payload.top_k]
-
-    def _index_document(
-        self,
-        *,
-        document: KnowledgeDocument,
-        content_text: str,
-        chunk_size: int | None,
-        chunk_overlap: int | None) -> list[KnowledgeChunk]:
-        source_type = document.source_type or "text"
-        resolved_size = chunk_size or self.settings.default_chunk_size
-        resolved_overlap = chunk_overlap or self.settings.default_chunk_overlap
-        chunk_payloads = chunk_document(
-            content_text=content_text,
-            source_type=source_type,
-            chunk_size=resolved_size,
-            chunk_overlap=resolved_overlap)
-        for chunk_payload in chunk_payloads:
-            content_text = self._read_chunk_content(chunk_payload)
-            embedding_result = self.embedding_service.embed_text(content_text)
-            chunk_payload["embedding_model"] = embedding_result.model
-            chunk_payload["embedding_json"] = embedding_result.embedding
-            chunk_payload["metadata_json"] = {
-                "embedding_provider": embedding_result.provider,
-            }
-        return self.chunk_repository.replace_document_chunks(
-            knowledge_base_id=document.knowledge_base_id,
-            document_id=document.id,
-            chunks=chunk_payloads)
-
-    def _read_chunk_content(self, chunk_payload: dict[str, JSONValue]) -> str:
-        value = chunk_payload.get("content_text")
-        return value if isinstance(value, str) else ""
-
-    def _parse_document_for_indexing(self, *, document: KnowledgeDocument) -> ParsedDocument:
-        metadata = document.metadata_json or {}
-        object_metadata = metadata.get("object_storage")
-        if isinstance(object_metadata, dict):
-            object_key = object_metadata.get("objectKey")
-            if isinstance(object_key, str) and object_key:
-                try:
-                    raw_content = self.object_storage.get_bytes(object_key=object_key)
-                except ObjectStorageNotFoundError as exc:
-                    raise ValueError(f"knowledge document content object not found: {document.id}") from exc
-                return parse_document_content(
-                    source_type=document.source_type,
-                    source_uri=document.source_uri,
-                    content_base64=base64.b64encode(raw_content).decode("ascii"))
-        if document.content_text:
-            return parse_document_content(
-                source_type=document.source_type,
-                source_uri=document.source_uri,
-                content_text=document.content_text)
-        raise ValueError(f"knowledge document content object not found: {document.id}")
+        return self.crud_service.read_storage_health()
 
 
-    def _read_document_content_for_indexing(self, *, document: KnowledgeDocument) -> str:
-        return self._parse_document_for_indexing(document=document).content_text
+    # ── Chunk CRUD ────────────────────────────────────────────────────
 
 
-    def _read_document_raw_content(self, *, document: KnowledgeDocument) -> bytes:
-        object_key = self._read_document_object_key(document=document)
-        if isinstance(object_key, str) and object_key:
-            return self.object_storage.get_bytes(object_key=object_key)
-        if document.content_text:
-            return document.content_text.encode("utf-8")
-        raise ValueError(f"knowledge document content object not found: {document.id}")
+    def list_chunks_filtered(self, *, knowledge_base_id: str | None = None, document_id: str | None = None, keyword: str | None = None) -> list[KnowledgeChunk]:
+        return self.crud_service.list_chunks_filtered(
+            knowledge_base_id=knowledge_base_id, document_id=document_id, keyword=keyword)
 
 
-    def _delete_document_object(self, *, document: KnowledgeDocument) -> bool:
-        object_key = self._read_document_object_key(document=document)
-        if object_key is None:
-            return False
-        try:
-            return self.object_storage.delete_object(object_key=object_key)
-        except ObjectStorageNotFoundError:
-            return False
+    def delete_chunk(self, *, chunk_id: str) -> bool:
+        return self.crud_service.delete_chunk(chunk_id=chunk_id)
 
 
-    def _read_document_object_key(self, *, document: KnowledgeDocument) -> str | None:
-        object_metadata = self._read_object_storage_metadata(document=document)
-        if object_metadata is None:
-            return None
-        object_key = object_metadata.get("objectKey")
-        return object_key if isinstance(object_key, str) and object_key else None
+    # ── Parse ─────────────────────────────────────────────────────────
 
 
-    def _read_object_storage_metadata(
-        self,
-        *,
-        document: KnowledgeDocument) -> dict[str, JSONValue] | None:
-        metadata = document.metadata_json or {}
-        object_metadata = metadata.get("object_storage")
-        return object_metadata if isinstance(object_metadata, dict) else None
+    def parse_document(self, payload: KnowledgeDocumentParseRequest):
+        return self.crud_service.parse_document(payload)
 
 
-    def _object_status_to_payload(
-        self,
-        *,
-        document: KnowledgeDocument,
-        status: ObjectStorageStatus) -> dict[str, JSONValue]:
-        return {
-            "documentId": document.id,
-            "exists": status.exists,
-            "objectStorage": self._read_object_storage_metadata(document=document),
-            "contentType": status.content_type,
-            "sizeBytes": status.size_bytes,
-            "etag": status.etag,
-            "errorMessage": status.error_message,
-        }
+    # ── Indexing ──────────────────────────────────────────────────────
 
 
-    def _read_content_type_from_status(
-        self,
-        object_status: dict[str, JSONValue] | None) -> str | None:
-        if object_status is None:
-            return None
-        content_type = object_status.get("contentType")
-        return content_type if isinstance(content_type, str) else None
+    def create_document(self, payload: KnowledgeDocumentCreateRequest) -> tuple[KnowledgeDocument, list[KnowledgeChunk]]:
+        return self.indexing_service.create_document(payload)
 
 
-    def _is_text_content_type(self, *, content_type: str | None, source_type: str) -> bool:
-        if content_type is not None and content_type.startswith("text/"):
-            return True
-        return source_type.strip().lower() in {"text", "txt", "markdown", "md", "html", "htm", "json", "csv"}
+    def create_document_from_contract(self, payload: KnowledgeDocumentCreateRequestDto) -> tuple[KnowledgeDocument, list[KnowledgeChunk]]:
+        return self.indexing_service.create_document_from_contract(payload)
 
 
-    def _should_queue_indexing(self, *, async_mode: bool | None) -> bool:
-        if async_mode is False:
-            return False
-        if not self.settings.async_indexing_enabled and async_mode is None:
-            return False
-        return self.task_queue_publisher is not None
+    def create_document_from_contract_result(self, payload: KnowledgeDocumentCreateRequestDto) -> KnowledgeDocumentIngestResult:
+        return self.indexing_service.create_document_from_contract_result(payload)
 
 
-    def _publish_document_index_job(
-        self,
-        *,
-        document_id: str,
-        action: str,
-        job_id: str) -> bool:
-        if self.task_queue_publisher is None:
-            return False
-        return self.task_queue_publisher.publish_knowledge_document(
-            document_id=document_id,
-            action=action,
-            job_id=job_id)
+    def create_document_index_job(self, payload: KnowledgeDocumentCreateRequest) -> KnowledgeDocumentIngestResult:
+        return self.indexing_service.create_document_index_job(payload)
 
 
-    def _write_index_job_metadata(
-        self,
-        *,
-        document: KnowledgeDocument,
-        action: str,
-        job_id: str,
-        status: str,
-        progress: int,
-        chunk_size: int | None = None,
-        chunk_overlap: int | None = None,
-        worker_key: str | None = None,
-        started_time: datetime | None = None,
-        completed_time: datetime | None = None,
-        error_message: str | None = None) -> dict[str, JSONValue]:
-        metadata = dict(document.metadata_json or {})
-        existing_job = self._read_index_job_payload(document=document)
-        queued_time = existing_job.get("queuedTime")
-        if not isinstance(queued_time, str):
-            queued_time = datetime.utcnow().isoformat()
-        resolved_started_time = (
-            started_time.isoformat()
-            if started_time is not None
-            else existing_job.get("startedTime")
-        )
-        resolved_completed_time = (
-            completed_time.isoformat()
-            if completed_time is not None
-            else existing_job.get("completedTime")
-        )
-        job_payload: dict[str, JSONValue] = {
-            "jobId": job_id,
-            "documentId": document.id,
-            "knowledgeBaseId": document.knowledge_base_id,
-            "documentTitle": document.title,
-            "action": action if action in {"index", "reindex"} else "reindex",
-            "status": status,
-            "progress": max(0, min(progress, 100)),
-            "queueName": KNOWLEDGE_DOCUMENT_QUEUE,
-            "workerKey": worker_key,
-            "errorMessage": error_message,
-            "chunkSize": chunk_size,
-            "chunkOverlap": chunk_overlap,
-            "queuedTime": queued_time,
-            "startedTime": resolved_started_time if isinstance(resolved_started_time, str) else None,
-            "completedTime": resolved_completed_time if isinstance(resolved_completed_time, str) else None,
-        }
-        metadata["index_job"] = job_payload
-        return metadata
+    def queue_document_indexing(self, *, document: KnowledgeDocument, action: str, chunk_size: int | None = None, chunk_overlap: int | None = None) -> tuple[KnowledgeDocument, KnowledgeIndexJobData]:
+        return self.indexing_service.queue_document_indexing(
+            document=document, action=action, chunk_size=chunk_size, chunk_overlap=chunk_overlap)
 
 
-    def _read_latest_index_job(self, *, document: KnowledgeDocument) -> KnowledgeIndexJobData:
-        payload = self._read_index_job_payload(document=document)
-        return KnowledgeIndexJobData(
-            jobId=self._read_payload_string(payload, "jobId") or f"kjob_{document.id}",
-            documentId=self._read_payload_string(payload, "documentId") or document.id,
-            knowledgeBaseId=self._read_payload_string(payload, "knowledgeBaseId") or document.knowledge_base_id,
-            documentTitle=self._read_payload_string(payload, "documentTitle") or document.title,
-            action=self._read_job_action(payload.get("action")),
-            status=self._read_job_status(payload.get("status")),
-            progress=self._read_payload_int(payload, "progress", 0),
-            queueName=self._read_payload_string(payload, "queueName"),
-            workerKey=self._read_payload_string(payload, "workerKey"),
-            errorMessage=self._read_payload_string(payload, "errorMessage"),
-            chunkSize=self._read_optional_payload_int(payload, "chunkSize"),
-            chunkOverlap=self._read_optional_payload_int(payload, "chunkOverlap"),
-            queuedTime=self._read_payload_datetime(payload, "queuedTime"),
-            startedTime=self._read_payload_datetime(payload, "startedTime"),
-            completedTime=self._read_payload_datetime(payload, "completedTime"))
+    def process_document_index_job(self, *, document_id: str, action: str, worker_key: str, job_id: str | None = None, chunk_size: int | None = None, chunk_overlap: int | None = None) -> tuple[KnowledgeDocument, list[KnowledgeChunk]] | None:
+        return self.indexing_service.process_document_index_job(
+            document_id=document_id, action=action, worker_key=worker_key,
+            job_id=job_id, chunk_size=chunk_size, chunk_overlap=chunk_overlap)
 
 
-    def _try_read_latest_index_job(self, *, document: KnowledgeDocument) -> KnowledgeIndexJobData | None:
-        if not self._read_index_job_payload(document=document):
-            return None
-        return self._read_latest_index_job(document=document)
+    def execute_document_index_job(self, *, document_id: str, action: str, worker_key: str, lease_seconds: int, job_id: str | None = None, redis_client: Redis | None = None) -> tuple[KnowledgeDocument, list[KnowledgeChunk]] | None:
+        return self.indexing_service.execute_document_index_job(
+            document_id=document_id, action=action, worker_key=worker_key,
+            lease_seconds=lease_seconds, job_id=job_id, redis_client=redis_client)
 
 
-    def _read_index_job_payload(self, *, document: KnowledgeDocument) -> dict[str, JSONValue]:
-        metadata = document.metadata_json or {}
-        value = metadata.get("index_job")
-        if isinstance(value, dict):
-            return {str(item_key): item_value for item_key, item_value in value.items()}
-        return {}
+    def execute_next_pending_document_job(self, *, worker_key: str, lease_seconds: int, stale_indexing_seconds: int, redis_client: Redis | None = None) -> tuple[KnowledgeDocument, list[KnowledgeChunk]] | None:
+        return self.indexing_service.execute_next_pending_document_job(
+            worker_key=worker_key, lease_seconds=lease_seconds,
+            stale_indexing_seconds=stale_indexing_seconds, redis_client=redis_client)
 
 
-    def _read_payload_string(
-        self,
-        payload: dict[str, JSONValue],
-        key: str) -> str | None:
-        value = payload.get(key)
-        return value if isinstance(value, str) and value else None
+    def list_index_jobs(self, *, knowledge_base_id: str | None = None, document_id: str | None = None, status: str | None = None) -> list[KnowledgeIndexJobData]:
+        return self.indexing_service.list_index_jobs(
+            knowledge_base_id=knowledge_base_id, document_id=document_id, status=status)
 
 
-    def _read_payload_int(
-        self,
-        payload: dict[str, JSONValue],
-        key: str,
-        fallback: int) -> int:
-        value = payload.get(key)
-        if isinstance(value, int) and not isinstance(value, bool):
-            return value
-        return fallback
-
-    def _read_optional_payload_int(
-        self,
-        payload: dict[str, JSONValue],
-        key: str) -> int | None:
-        value = payload.get(key)
-        if isinstance(value, int) and not isinstance(value, bool):
-            return value
-        return None
+    def detail_index_job(self, *, document_id: str) -> KnowledgeIndexJobData | None:
+        return self.indexing_service.detail_index_job(document_id=document_id)
 
 
-    def _read_payload_datetime(
-        self,
-        payload: dict[str, JSONValue],
-        key: str) -> datetime | None:
-        value = payload.get(key)
-        if not isinstance(value, str) or not value:
-            return None
-        try:
-            return datetime.fromisoformat(value)
-        except ValueError:
-            return None
+    def reindex_document(self, payload: KnowledgeDocumentReindexRequestDto) -> tuple[KnowledgeDocument, list[KnowledgeChunk]] | None:
+        return self.indexing_service.reindex_document(payload)
 
 
-    def _read_job_action(self, value: JSONValue) -> KnowledgeIndexJobAction:
-        if isinstance(value, str) and value in {"index", "reindex"}:
-            return cast(KnowledgeIndexJobAction, value)
-        return "reindex"
+    def reindex_document_from_contract_result(self, payload: KnowledgeDocumentReindexRequestDto) -> KnowledgeDocumentIngestResult | None:
+        return self.indexing_service.reindex_document_from_contract_result(payload)
 
 
-    def _read_job_status(self, value: JSONValue) -> KnowledgeIndexJobStatus:
-        if isinstance(value, str) and value in {"queued", "running", "completed", "failed", "skipped"}:
-            return cast(KnowledgeIndexJobStatus, value)
-        return "queued"
+    def reindex_base_from_contract(self, payload: KnowledgeBaseReindexRequestDto) -> list[KnowledgeIndexJobData]:
+        return self.indexing_service.reindex_base_from_contract(payload)
 
 
-    def _mark_document_failed(
-        self,
-        *,
-        document: KnowledgeDocument,
-        message: str,
-        job_id: str | None = None,
-        action: str = "reindex",
-        worker_key: str | None = None,
-        chunk_size: int | None = None,
-        chunk_overlap: int | None = None) -> None:
-        metadata = dict(document.metadata_json or {})
-        metadata["last_error"] = {
-            "message": message[:1000],
-            "errorType": "indexing_failed",
-        }
-        if job_id is not None:
-            metadata = self._write_index_job_metadata(
-                document=document,
-                action=action,
-                job_id=job_id,
-                status="failed",
-                progress=100,
-                chunk_size=chunk_size,
-                chunk_overlap=chunk_overlap,
-                worker_key=worker_key,
-                completed_time=datetime.utcnow(),
-                error_message=message[:1000])
-        self.document_repository.update(
-            document_id=document.id,
-            status="failed",
-            metadata_json=metadata)
+    # ── Search ────────────────────────────────────────────────────────
 
 
-    def _guess_content_type(self, *, source_type: str) -> str:
-        normalized = source_type.strip().lower().removeprefix(".")
-        if normalized in {"markdown", "md"}:
-            return "text/markdown; charset=utf-8"
-        if normalized in {"html", "htm"}:
-            return "text/html; charset=utf-8"
-        if normalized == "json":
-            return "application/json"
-        if normalized == "csv":
-            return "text/csv; charset=utf-8"
-        if normalized == "pdf":
-            return "application/pdf"
-        if normalized in {"docx", "word"}:
-            return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
-        return "text/plain; charset=utf-8"
+    def search(self, payload: KnowledgeSearchRequest) -> list[tuple[KnowledgeChunk, KnowledgeDocument, float, dict[str, JSONValue]]]:
+        return self.search_service.search(payload)
 
 
-    def _matches_filters(
-        self,
-        *,
-        document: KnowledgeDocument,
-        filters_json: dict[str, JSONValue]) -> bool:
-        source_type = filters_json.get("sourceType") or filters_json.get("source_type")
-        if isinstance(source_type, str) and document.source_type != source_type:
-            return False
-        status = filters_json.get("status")
-        if isinstance(status, str) and document.status != status:
-            return False
-        return True
+    # ── Settings ──────────────────────────────────────────────────────
 
 
     def read_settings(self, *, knowledge_base_id: str | None = None) -> KnowledgeSettingsDto:
     def read_settings(self, *, knowledge_base_id: str | None = None) -> KnowledgeSettingsDto:
-        base_config: dict[str, JSONValue] = {}
-        if knowledge_base_id:
-            base = self.base_repository.get_by_id(knowledge_base_id=knowledge_base_id)
-            if base is not None and isinstance(base.metadata_json, dict):
-                value = base.metadata_json.get("retrieval_config")
-                if isinstance(value, dict):
-                    base_config = value
-        defaults = KnowledgeSettingsDto(
-            knowledgeBaseId=knowledge_base_id,
-            chunkSize=self.settings.default_chunk_size,
-            chunkOverlap=self.settings.default_chunk_overlap,
-            keywordWeight=self.settings.retrieval_keyword_weight,
-            vectorWeight=self.settings.retrieval_vector_weight,
-            rerankWeight=self.settings.retrieval_rerank_weight,
-            queryRewrite=False,
-            requireCitations=True)
-        return KnowledgeSettingsDto.model_validate({
-            **defaults.model_dump(),
-            **base_config,
-            "knowledgeBaseId": knowledge_base_id,
-        })
+        return self.settings_service.read_settings(knowledge_base_id=knowledge_base_id)
 
 
-    def update_settings(
-        self,
-        payload: KnowledgeSettingsUpdateRequestDto) -> KnowledgeSettingsDto:
-        settings = KnowledgeSettingsDto.model_validate({
-            **payload.model_dump(),
-            "knowledgeBaseId": payload.knowledgeBaseId,
-        })
-        if payload.knowledgeBaseId:
-            base = self.base_repository.get_by_id(knowledge_base_id=payload.knowledgeBaseId)
-            if base is not None:
-                metadata = dict(base.metadata_json or {})
-                metadata["retrieval_config"] = settings.model_dump(exclude={"knowledgeBaseId"})
-                self.base_repository.update(
-                    knowledge_base_id=payload.knowledgeBaseId,
-                    metadata_json=metadata)
-        return settings
-
-    def _build_base_code(self, name: str) -> str:
-        base = "".join(
-            char.lower() if char.isalnum() else "_"
-            for char in name
-        ).strip("_") or "knowledge_base"
-        return base[:64]
+    def update_settings(self, payload: KnowledgeSettingsUpdateRequestDto) -> KnowledgeSettingsDto:
+        return self.settings_service.update_settings(payload)
 
 
 
 
 def build_knowledge_application_service(
 def build_knowledge_application_service(
     *,
     *,
-    db: Session,
-    settings: KnowledgeServiceSettings) -> KnowledgeApplicationService:
+    db,
+    settings: KnowledgeServiceSettings,
+) -> KnowledgeApplicationService:
+    from app.domain.repositories import (
+        KnowledgeBaseRepository,
+        KnowledgeChunkRepository,
+        KnowledgeDocumentRepository,
+    )
+
     redis_client = try_build_redis_client(settings.redis_url)
     redis_client = try_build_redis_client(settings.redis_url)
     return KnowledgeApplicationService(
     return KnowledgeApplicationService(
         settings=settings,
         settings=settings,

+ 70 - 0
services/knowledge-service/app/application/settings_service.py

@@ -0,0 +1,70 @@
+"""Knowledge settings read/write sub-service."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from core_shared import JSONValue
+
+from app.bootstrap.settings import KnowledgeServiceSettings
+from app.schemas.knowledge import (
+    KnowledgeSettingsDto,
+    KnowledgeSettingsUpdateRequestDto,
+)
+
+if TYPE_CHECKING:
+    from app.domain.repositories import KnowledgeBaseRepository
+
+
+class KnowledgeSettingsService:
+    def __init__(
+        self,
+        *,
+        settings: KnowledgeServiceSettings,
+        base_repository: KnowledgeBaseRepository,
+    ) -> None:
+        self.settings = settings
+        self.base_repository = base_repository
+
+    def read_settings(self, *, knowledge_base_id: str | None = None) -> KnowledgeSettingsDto:
+        base_config: dict[str, JSONValue] = {}
+        if knowledge_base_id:
+            base = self.base_repository.get_by_id(knowledge_base_id=knowledge_base_id)
+            if base is not None and isinstance(base.metadata_json, dict):
+                value = base.metadata_json.get("retrieval_config")
+                if isinstance(value, dict):
+                    base_config = value
+        defaults = KnowledgeSettingsDto(
+            knowledgeBaseId=knowledge_base_id,
+            chunkSize=self.settings.default_chunk_size,
+            chunkOverlap=self.settings.default_chunk_overlap,
+            keywordWeight=self.settings.retrieval_keyword_weight,
+            vectorWeight=self.settings.retrieval_vector_weight,
+            rerankWeight=self.settings.retrieval_rerank_weight,
+            queryRewrite=False,
+            requireCitations=True,
+        )
+        return KnowledgeSettingsDto.model_validate({
+            **defaults.model_dump(),
+            **base_config,
+            "knowledgeBaseId": knowledge_base_id,
+        })
+
+    def update_settings(
+        self,
+        payload: KnowledgeSettingsUpdateRequestDto,
+    ) -> KnowledgeSettingsDto:
+        settings = KnowledgeSettingsDto.model_validate({
+            **payload.model_dump(),
+            "knowledgeBaseId": payload.knowledgeBaseId,
+        })
+        if payload.knowledgeBaseId:
+            base = self.base_repository.get_by_id(knowledge_base_id=payload.knowledgeBaseId)
+            if base is not None:
+                metadata = dict(base.metadata_json or {})
+                metadata["retrieval_config"] = settings.model_dump(exclude={"knowledgeBaseId"})
+                self.base_repository.update(
+                    knowledge_base_id=payload.knowledgeBaseId,
+                    metadata_json=metadata,
+                )
+        return settings

+ 5 - 4
services/memory-service/app/api/routes.py

@@ -3,6 +3,7 @@ from typing import TypeVar
 from uuid import uuid4
 from uuid import uuid4
 
 
 from core_domain import ServiceHealth
 from core_domain import ServiceHealth
+from core_shared import error_detail
 from fastapi import APIRouter, Depends, HTTPException, Request
 from fastapi import APIRouter, Depends, HTTPException, Request
 from sqlalchemy import text
 from sqlalchemy import text
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
@@ -76,7 +77,7 @@ def detail_memory_contract(
     service: MemoryApplicationService = Depends(get_memory_application_service)) -> ApiResponse[MemoryItemDto]:
     service: MemoryApplicationService = Depends(get_memory_application_service)) -> ApiResponse[MemoryItemDto]:
     entity = service.get_memory(memory_id=payload.memoryId)
     entity = service.get_memory(memory_id=payload.memoryId)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"memory not found: {payload.memoryId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.memory.not_found", id=payload.memoryId))
     return ok(MemoryItemDto.from_entity(entity))
     return ok(MemoryItemDto.from_entity(entity))
 
 
 
 
@@ -86,7 +87,7 @@ def update_memory_contract(
     service: MemoryApplicationService = Depends(get_memory_application_service)) -> ApiResponse[MemoryItemDto]:
     service: MemoryApplicationService = Depends(get_memory_application_service)) -> ApiResponse[MemoryItemDto]:
     entity = service.update_memory(payload)
     entity = service.update_memory(payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"memory not found: {payload.memoryId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.memory.not_found", id=payload.memoryId))
     return ok(MemoryItemDto.from_entity(entity))
     return ok(MemoryItemDto.from_entity(entity))
 
 
 
 
@@ -98,7 +99,7 @@ def update_memory_status_contract(
         memory_id=payload.memoryId,
         memory_id=payload.memoryId,
         payload=payload)
         payload=payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"memory not found: {payload.memoryId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.memory.not_found", id=payload.memoryId))
     return ok(MemoryItemDto.from_entity(entity))
     return ok(MemoryItemDto.from_entity(entity))
 
 
 
 
@@ -108,7 +109,7 @@ def delete_memory_contract(
     service: MemoryApplicationService = Depends(get_memory_application_service)) -> ApiResponse[DeleteData]:
     service: MemoryApplicationService = Depends(get_memory_application_service)) -> ApiResponse[DeleteData]:
     entity = service.delete_memory(memory_id=payload.memoryId)
     entity = service.delete_memory(memory_id=payload.memoryId)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"memory not found: {payload.memoryId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.memory.not_found", id=payload.memoryId))
     return ok(DeleteData(deleted=True, memoryId=payload.memoryId))
     return ok(DeleteData(deleted=True, memoryId=payload.memoryId))
 
 
 
 

+ 19 - 18
services/model-gateway-service/app/api/routes.py

@@ -9,6 +9,7 @@ from core_domain import (
     EmbeddingResponseContract,
     EmbeddingResponseContract,
     ServiceHealth,
     ServiceHealth,
 )
 )
+from core_shared import error_detail
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi.responses import StreamingResponse
 from fastapi.responses import StreamingResponse
 from sqlalchemy import text
 from sqlalchemy import text
@@ -105,7 +106,7 @@ def create_model(
     try:
     try:
         entity = service.create_model(payload)
         entity = service.create_model(payload)
     except ValueError as exc:
     except ValueError as exc:
-        raise HTTPException(status_code=422, detail=str(exc)) from exc
+        raise HTTPException(status_code=422, detail=error_detail("error.validation", message=str(exc))) from exc
     return ModelResponse.from_entity(entity)
     return ModelResponse.from_entity(entity)
 
 
 
 
@@ -125,9 +126,9 @@ def update_model(
     try:
     try:
         entity = service.update_model(model_id=model_id, payload=payload)
         entity = service.update_model(model_id=model_id, payload=payload)
     except ValueError as exc:
     except ValueError as exc:
-        raise HTTPException(status_code=422, detail=str(exc)) from exc
+        raise HTTPException(status_code=422, detail=error_detail("error.validation", message=str(exc))) from exc
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"model not found: {model_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.model.not_found", id=model_id))
     return ModelResponse.from_entity(entity)
     return ModelResponse.from_entity(entity)
 
 
 
 
@@ -139,7 +140,7 @@ def update_model_status(
 ) -> ModelResponse:
 ) -> ModelResponse:
     entity = service.update_model_status(model_id=model_id, payload=payload)
     entity = service.update_model_status(model_id=model_id, payload=payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"model not found: {model_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.model.not_found", id=model_id))
     return ModelResponse.from_entity(entity)
     return ModelResponse.from_entity(entity)
 
 
 
 
@@ -149,7 +150,7 @@ def delete_model(
     service: ModelServiceDep,
     service: ModelServiceDep,
 ) -> None:
 ) -> None:
     if not service.delete_model(model_id):
     if not service.delete_model(model_id):
-        raise HTTPException(status_code=404, detail=f"model not found: {model_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.model.not_found", id=model_id))
 
 
 
 
 @router.post("/{model_id}/test", response_model=ModelTestResponse)
 @router.post("/{model_id}/test", response_model=ModelTestResponse)
@@ -161,9 +162,9 @@ def test_model(
     try:
     try:
         result = service.test_model(model_id=model_id, payload=payload)
         result = service.test_model(model_id=model_id, payload=payload)
     except ModelProviderClientError as exc:
     except ModelProviderClientError as exc:
-        raise HTTPException(status_code=502, detail=str(exc)) from exc
+        raise HTTPException(status_code=502, detail=error_detail("error.downstream.request_failed", service="model-gateway", error=str(exc))) from exc
     if result is None:
     if result is None:
-        raise HTTPException(status_code=404, detail=f"model not found: {model_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.model.not_found", id=model_id))
     return result
     return result
 
 
 
 
@@ -174,7 +175,7 @@ def create_chat_completion(
     try:
     try:
         return service.create_chat_completion(payload)
         return service.create_chat_completion(payload)
     except ModelProviderClientError as exc:
     except ModelProviderClientError as exc:
-        raise HTTPException(status_code=502, detail=str(exc)) from exc
+        raise HTTPException(status_code=502, detail=error_detail("error.downstream.request_failed", service="model-gateway", error=str(exc))) from exc
 
 
 
 
 @router.post("/chat-completions/stream")
 @router.post("/chat-completions/stream")
@@ -205,7 +206,7 @@ def create_embedding(
     try:
     try:
         return service.create_embedding(payload)
         return service.create_embedding(payload)
     except ModelProviderClientError as exc:
     except ModelProviderClientError as exc:
-        raise HTTPException(status_code=502, detail=str(exc)) from exc
+        raise HTTPException(status_code=502, detail=error_detail("error.downstream.request_failed", service="model-gateway", error=str(exc))) from exc
 
 
 
 
 @router.post("/list", response_model=ApiResponse[PageResult[ModelDto]])
 @router.post("/list", response_model=ApiResponse[PageResult[ModelDto]])
@@ -237,7 +238,7 @@ def create_model_contract(
     try:
     try:
         entity = service.create_model_from_contract(payload)
         entity = service.create_model_from_contract(payload)
     except ValueError as exc:
     except ValueError as exc:
-        raise HTTPException(status_code=422, detail=str(exc)) from exc
+        raise HTTPException(status_code=422, detail=error_detail("error.validation", message=str(exc))) from exc
     return ok(ModelDto.from_entity(entity))
     return ok(ModelDto.from_entity(entity))
 
 
 
 
@@ -248,9 +249,9 @@ def update_model_contract(
     try:
     try:
         entity = service.update_model_from_contract(payload)
         entity = service.update_model_from_contract(payload)
     except ValueError as exc:
     except ValueError as exc:
-        raise HTTPException(status_code=422, detail=str(exc)) from exc
+        raise HTTPException(status_code=422, detail=error_detail("error.validation", message=str(exc))) from exc
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"model not found: {payload.modelId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.model.not_found", id=payload.modelId))
     return ok(ModelDto.from_entity(entity))
     return ok(ModelDto.from_entity(entity))
 
 
 
 
@@ -269,9 +270,9 @@ def test_model_contract(
     try:
     try:
         result = service.test_model_from_contract(payload)
         result = service.test_model_from_contract(payload)
     except ModelProviderClientError as exc:
     except ModelProviderClientError as exc:
-        raise HTTPException(status_code=502, detail=str(exc)) from exc
+        raise HTTPException(status_code=502, detail=error_detail("error.downstream.request_failed", service="model-gateway", error=str(exc))) from exc
     if result is None:
     if result is None:
-        raise HTTPException(status_code=404, detail=f"model not found: {payload.modelId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.model.not_found", id=payload.modelId))
     return ok(result)
     return ok(result)
 
 
 
 
@@ -311,7 +312,7 @@ def update_model_provider_contract(
     service: ModelServiceDep) -> ApiResponse[ModelProviderDto]:
     service: ModelServiceDep) -> ApiResponse[ModelProviderDto]:
     entity = service.update_provider(payload)
     entity = service.update_provider(payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"provider not found: {payload.providerId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.provider.not_found", id=payload.providerId))
     return ok(ModelProviderDto.from_entity(entity))
     return ok(ModelProviderDto.from_entity(entity))
 
 
 
 
@@ -330,9 +331,9 @@ def test_model_provider_contract(
     try:
     try:
         result = service.test_provider(payload)
         result = service.test_provider(payload)
     except ModelProviderClientError as exc:
     except ModelProviderClientError as exc:
-        raise HTTPException(status_code=502, detail=str(exc)) from exc
+        raise HTTPException(status_code=502, detail=error_detail("error.downstream.request_failed", service="model-gateway", error=str(exc))) from exc
     if result is None:
     if result is None:
-        raise HTTPException(status_code=404, detail=f"provider not found: {payload.providerId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.provider.not_found", id=payload.providerId))
     return ok(result)
     return ok(result)
 
 
 
 
@@ -343,4 +344,4 @@ def discover_models_contract(
     try:
     try:
         return ok(service.discover_models(payload))
         return ok(service.discover_models(payload))
     except ModelProviderClientError as exc:
     except ModelProviderClientError as exc:
-        raise HTTPException(status_code=502, detail=str(exc)) from exc
+        raise HTTPException(status_code=502, detail=error_detail("error.downstream.request_failed", service="model-gateway", error=str(exc))) from exc

+ 3 - 3
services/scheduler-service/app/api/routes.py

@@ -1,5 +1,5 @@
 from core_domain import ScheduledJobStatus, ScheduledJobType, ServiceHealth
 from core_domain import ScheduledJobStatus, ScheduledJobType, ServiceHealth
-from core_shared import try_build_redis_client
+from core_shared import error_detail, try_build_redis_client
 from core_shared.task_queue import TaskQueuePublisher
 from core_shared.task_queue import TaskQueuePublisher
 from fastapi import APIRouter, Depends, HTTPException, Query, Request
 from fastapi import APIRouter, Depends, HTTPException, Query, Request
 from sqlalchemy import text
 from sqlalchemy import text
@@ -91,7 +91,7 @@ def update_job_status(
     service: SchedulerApplicationService = Depends(get_scheduler_application_service)) -> ScheduledJobResponse:
     service: SchedulerApplicationService = Depends(get_scheduler_application_service)) -> ScheduledJobResponse:
     entity = service.update_job_status(job_id=job_id, payload=payload)
     entity = service.update_job_status(job_id=job_id, payload=payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"scheduled job not found: {job_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.scheduled_job.not_found", id=job_id))
     return ScheduledJobResponse.from_entity(entity)
     return ScheduledJobResponse.from_entity(entity)
 
 
 
 
@@ -105,5 +105,5 @@ def update_job_status_post(
             status=payload.status,
             status=payload.status,
             last_error_message=payload.last_error_message))
             last_error_message=payload.last_error_message))
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"scheduled job not found: {payload.job_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.scheduled_job.not_found", id=payload.job_id))
     return ScheduledJobResponse.from_entity(entity)
     return ScheduledJobResponse.from_entity(entity)

+ 4 - 3
services/session-service/app/api/routes.py

@@ -1,4 +1,5 @@
 from core_domain import ServiceHealth
 from core_domain import ServiceHealth
+from core_shared import error_detail
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import text
 from sqlalchemy import text
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
@@ -67,7 +68,7 @@ def detail_session(
     service: SessionApplicationService = Depends(get_session_application_service)) -> SessionResponse:
     service: SessionApplicationService = Depends(get_session_application_service)) -> SessionResponse:
     entity = service.get_session(session_id=payload.session_id)
     entity = service.get_session(session_id=payload.session_id)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"session not found: {payload.session_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.session.not_found", id=payload.session_id))
     return SessionResponse.from_entity(entity)
     return SessionResponse.from_entity(entity)
 
 
 
 
@@ -133,7 +134,7 @@ def get_run_request(
     service: SessionApplicationService = Depends(get_session_application_service)) -> RunRequestResponse:
     service: SessionApplicationService = Depends(get_session_application_service)) -> RunRequestResponse:
     entity = service.run_request_repository.get_by_id(run_request_id=payload.run_request_id)
     entity = service.run_request_repository.get_by_id(run_request_id=payload.run_request_id)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"run_request not found: {payload.run_request_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.run_request.not_found", id=payload.run_request_id))
     return RunRequestResponse.from_entity(entity)
     return RunRequestResponse.from_entity(entity)
 
 
 
 
@@ -143,5 +144,5 @@ def update_run_request(
     service: SessionApplicationService = Depends(get_session_application_service)) -> RunRequestResponse:
     service: SessionApplicationService = Depends(get_session_application_service)) -> RunRequestResponse:
     entity = service.update_run_request(payload)
     entity = service.update_run_request(payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"run_request not found: {payload.run_request_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.run_request.not_found", id=payload.run_request_id))
     return RunRequestResponse.from_entity(entity)
     return RunRequestResponse.from_entity(entity)

+ 9 - 8
services/skill-service/app/api/routes.py

@@ -3,6 +3,7 @@ from typing import TypeVar
 from uuid import uuid4
 from uuid import uuid4
 
 
 from core_domain import ServiceHealth
 from core_domain import ServiceHealth
+from core_shared import error_detail
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import text
 from sqlalchemy import text
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
@@ -85,7 +86,7 @@ def detail_skill_contract(
     service: SkillApplicationService = Depends(get_skill_application_service)) -> ApiResponse[SkillDefinitionDto]:
     service: SkillApplicationService = Depends(get_skill_application_service)) -> ApiResponse[SkillDefinitionDto]:
     entity = service.skill_repository.get_by_id(skill_id=payload.skillId)
     entity = service.skill_repository.get_by_id(skill_id=payload.skillId)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"skill not found: {payload.skillId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.skill.not_found", id=payload.skillId))
     return ok(SkillDefinitionDto.from_entity(entity))
     return ok(SkillDefinitionDto.from_entity(entity))
 
 
 
 
@@ -95,7 +96,7 @@ def update_skill_contract(
     service: SkillApplicationService = Depends(get_skill_application_service)) -> ApiResponse[SkillDefinitionDto]:
     service: SkillApplicationService = Depends(get_skill_application_service)) -> ApiResponse[SkillDefinitionDto]:
     entity = service.update_skill(payload)
     entity = service.update_skill(payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"skill not found: {payload.skillId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.skill.not_found", id=payload.skillId))
     return ok(SkillDefinitionDto.from_entity(entity))
     return ok(SkillDefinitionDto.from_entity(entity))
 
 
 
 
@@ -105,7 +106,7 @@ def update_skill_status_contract(
     service: SkillApplicationService = Depends(get_skill_application_service)) -> ApiResponse[SkillDefinitionDto]:
     service: SkillApplicationService = Depends(get_skill_application_service)) -> ApiResponse[SkillDefinitionDto]:
     entity = service.update_skill_status_contract(payload)
     entity = service.update_skill_status_contract(payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"skill not found: {payload.skillId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.skill.not_found", id=payload.skillId))
     return ok(SkillDefinitionDto.from_entity(entity))
     return ok(SkillDefinitionDto.from_entity(entity))
 
 
 
 
@@ -115,7 +116,7 @@ def delete_skill_contract(
     service: SkillApplicationService = Depends(get_skill_application_service)) -> ApiResponse[DeleteData]:
     service: SkillApplicationService = Depends(get_skill_application_service)) -> ApiResponse[DeleteData]:
     entity = service.delete_skill(payload)
     entity = service.delete_skill(payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"skill not found: {payload.skillId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.skill.not_found", id=payload.skillId))
     return ok(DeleteData(deleted=True, skillId=payload.skillId))
     return ok(DeleteData(deleted=True, skillId=payload.skillId))
 
 
 
 
@@ -138,7 +139,7 @@ def install_skill_contract(
     try:
     try:
         return ok(SkillInstallationDto.from_entity(service.install_skill_from_contract(payload)))
         return ok(SkillInstallationDto.from_entity(service.install_skill_from_contract(payload)))
     except ValueError as exc:
     except ValueError as exc:
-        raise HTTPException(status_code=422, detail=str(exc)) from exc
+        raise HTTPException(status_code=422, detail=error_detail("error.validation", message=str(exc))) from exc
 
 
 
 
 @router.post("/installations/status", response_model=ApiResponse[SkillInstallationDto])
 @router.post("/installations/status", response_model=ApiResponse[SkillInstallationDto])
@@ -147,7 +148,7 @@ def update_installation_status_contract(
     service: SkillApplicationService = Depends(get_skill_application_service)) -> ApiResponse[SkillInstallationDto]:
     service: SkillApplicationService = Depends(get_skill_application_service)) -> ApiResponse[SkillInstallationDto]:
     entity = service.update_installation_status_contract(payload)
     entity = service.update_installation_status_contract(payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"skill installation not found: {payload.installationId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.skill_installation.not_found", id=payload.installationId))
     return ok(SkillInstallationDto.from_entity(entity))
     return ok(SkillInstallationDto.from_entity(entity))
 
 
 
 
@@ -158,7 +159,7 @@ def create_skill_run(
     try:
     try:
         return ok(SkillRunDto.from_entity(service.create_skill_run_from_contract(payload)))
         return ok(SkillRunDto.from_entity(service.create_skill_run_from_contract(payload)))
     except ValueError as exc:
     except ValueError as exc:
-        raise HTTPException(status_code=422, detail=str(exc)) from exc
+        raise HTTPException(status_code=422, detail=error_detail("error.validation", message=str(exc))) from exc
 
 
 
 
 @router.post("/runs/execute", response_model=ApiResponse[SkillRunDto])
 @router.post("/runs/execute", response_model=ApiResponse[SkillRunDto])
@@ -167,5 +168,5 @@ def execute_skill_run(
     service: SkillApplicationService = Depends(get_skill_application_service)) -> ApiResponse[SkillRunDto]:
     service: SkillApplicationService = Depends(get_skill_application_service)) -> ApiResponse[SkillRunDto]:
     entity = service.execute_skill_run_from_contract(payload)
     entity = service.execute_skill_run_from_contract(payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"skill_run not found: {payload.skillRunId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.skill_run.not_found", id=payload.skillRunId))
     return ok(SkillRunDto.from_entity(entity))
     return ok(SkillRunDto.from_entity(entity))

+ 18 - 18
services/team-service/app/api/routes.py

@@ -3,7 +3,7 @@ from datetime import datetime
 from typing import TypeVar
 from typing import TypeVar
 
 
 from core_domain import ServiceHealth
 from core_domain import ServiceHealth
-from core_shared import try_build_redis_client
+from core_shared import error_detail, try_build_redis_client
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi.responses import StreamingResponse
 from fastapi.responses import StreamingResponse
 from sqlalchemy import text
 from sqlalchemy import text
@@ -139,7 +139,7 @@ def get_team_contract(
     service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamDto]:
     service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamDto]:
     entity = service.get_team(team_id=payload.teamId)
     entity = service.get_team(team_id=payload.teamId)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"team not found: {payload.teamId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.team.not_found", id=payload.teamId))
     return ok(TeamDto.from_entity(entity))
     return ok(TeamDto.from_entity(entity))
 
 
 
 
@@ -149,7 +149,7 @@ def update_team_contract(
     service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamDto]:
     service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamDto]:
     entity = service.update_team_from_contract(payload)
     entity = service.update_team_from_contract(payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"team not found: {payload.teamId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.team.not_found", id=payload.teamId))
     return ok(TeamDto.from_entity(entity))
     return ok(TeamDto.from_entity(entity))
 
 
 
 
@@ -168,7 +168,7 @@ def update_team_status(
     service: TeamApplicationService = Depends(get_team_application_service)) -> TeamResponse:
     service: TeamApplicationService = Depends(get_team_application_service)) -> TeamResponse:
     entity = service.update_team_status(team_id=team_id, payload=payload)
     entity = service.update_team_status(team_id=team_id, payload=payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"team not found: {team_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.team.not_found", id=team_id))
     return TeamResponse.from_entity(entity)
     return TeamResponse.from_entity(entity)
 
 
 
 
@@ -192,7 +192,7 @@ def create_team_config_contract(
     try:
     try:
         entity = service.create_team_config_from_contract(payload)
         entity = service.create_team_config_from_contract(payload)
     except ValueError as exc:
     except ValueError as exc:
-        raise HTTPException(status_code=422, detail=str(exc)) from exc
+        raise HTTPException(status_code=422, detail=error_detail("error.validation", message=str(exc))) from exc
     return ok(TeamConfigDto.from_entity(entity))
     return ok(TeamConfigDto.from_entity(entity))
 
 
 
 
@@ -202,7 +202,7 @@ def get_team_config_contract(
     service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamConfigDto]:
     service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamConfigDto]:
     entity = service.get_team_config(config_id=payload.configId)
     entity = service.get_team_config(config_id=payload.configId)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"team config not found: {payload.configId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.team_config.not_found", id=payload.configId))
     return ok(TeamConfigDto.from_entity(entity))
     return ok(TeamConfigDto.from_entity(entity))
 
 
 
 
@@ -213,9 +213,9 @@ def update_team_config_contract(
     try:
     try:
         entity = service.update_team_config_from_contract(payload)
         entity = service.update_team_config_from_contract(payload)
     except ValueError as exc:
     except ValueError as exc:
-        raise HTTPException(status_code=422, detail=str(exc)) from exc
+        raise HTTPException(status_code=422, detail=error_detail("error.validation", message=str(exc))) from exc
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"team config not found: {payload.configId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.team_config.not_found", id=payload.configId))
     return ok(TeamConfigDto.from_entity(entity))
     return ok(TeamConfigDto.from_entity(entity))
 
 
 
 
@@ -234,7 +234,7 @@ def create_team_run(
     try:
     try:
         entity = service.create_team_run(payload)
         entity = service.create_team_run(payload)
     except ValueError as exc:
     except ValueError as exc:
-        raise HTTPException(status_code=422, detail=str(exc)) from exc
+        raise HTTPException(status_code=422, detail=error_detail("error.validation", message=str(exc))) from exc
     return TeamRunResponse.from_entity(entity)
     return TeamRunResponse.from_entity(entity)
 
 
 
 
@@ -275,7 +275,7 @@ def create_team_run_contract(
     try:
     try:
         entity = service.create_team_run_from_contract(payload)
         entity = service.create_team_run_from_contract(payload)
     except ValueError as exc:
     except ValueError as exc:
-        raise HTTPException(status_code=422, detail=str(exc)) from exc
+        raise HTTPException(status_code=422, detail=error_detail("error.validation", message=str(exc))) from exc
     return ok(TeamRunDto.from_entity(entity))
     return ok(TeamRunDto.from_entity(entity))
 
 
 
 
@@ -285,7 +285,7 @@ def get_team_run_contract(
     service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamRunDto]:
     service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamRunDto]:
     entity = service.get_team_run(team_run_id=payload.teamRunId)
     entity = service.get_team_run(team_run_id=payload.teamRunId)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"team_run not found: {payload.teamRunId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.team_run.not_found", id=payload.teamRunId))
     return ok(TeamRunDto.from_entity(entity))
     return ok(TeamRunDto.from_entity(entity))
 
 
 
 
@@ -295,7 +295,7 @@ def update_team_run_status_contract(
     service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamRunDto]:
     service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamRunDto]:
     entity = service.update_team_run_status_from_contract(payload)
     entity = service.update_team_run_status_from_contract(payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"team_run not found: {payload.teamRunId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.team_run.not_found", id=payload.teamRunId))
     return ok(TeamRunDto.from_entity(entity))
     return ok(TeamRunDto.from_entity(entity))
 
 
 
 
@@ -309,7 +309,7 @@ def execute_team_run_contract(
             worker_key=payload.workerKey,
             worker_key=payload.workerKey,
             dry_run=payload.dryRun))
             dry_run=payload.dryRun))
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"team_run not found: {payload.teamRunId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.team_run.not_found", id=payload.teamRunId))
     output_json = entity.output_json or {}
     output_json = entity.output_json or {}
     member_run_count = output_json.get("member_run_count")
     member_run_count = output_json.get("member_run_count")
     dry_run = output_json.get("dry_run")
     dry_run = output_json.get("dry_run")
@@ -324,7 +324,7 @@ def execute_team_run_stream_contract(
     payload: TeamRunExecuteRequestDto,
     payload: TeamRunExecuteRequestDto,
     service: TeamApplicationService = Depends(get_team_application_service)) -> StreamingResponse:
     service: TeamApplicationService = Depends(get_team_application_service)) -> StreamingResponse:
     if service.get_team_run(team_run_id=payload.teamRunId) is None:
     if service.get_team_run(team_run_id=payload.teamRunId) is None:
-        raise HTTPException(status_code=404, detail=f"team_run not found: {payload.teamRunId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.team_run.not_found", id=payload.teamRunId))
 
 
     def events():
     def events():
         for item in service.execute_team_run_stream(
         for item in service.execute_team_run_stream(
@@ -358,7 +358,7 @@ def update_team_run_status(
     service: TeamApplicationService = Depends(get_team_application_service)) -> TeamRunResponse:
     service: TeamApplicationService = Depends(get_team_application_service)) -> TeamRunResponse:
     entity = service.update_team_run_status(team_run_id=team_run_id, payload=payload)
     entity = service.update_team_run_status(team_run_id=team_run_id, payload=payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"team_run not found: {team_run_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.team_run.not_found", id=team_run_id))
     return TeamRunResponse.from_entity(entity)
     return TeamRunResponse.from_entity(entity)
 
 
 
 
@@ -369,7 +369,7 @@ def execute_team_run(
     service: TeamApplicationService = Depends(get_team_application_service)) -> TeamRunExecuteResponse:
     service: TeamApplicationService = Depends(get_team_application_service)) -> TeamRunExecuteResponse:
     entity = service.execute_team_run(team_run_id=team_run_id, payload=payload)
     entity = service.execute_team_run(team_run_id=team_run_id, payload=payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"team_run not found: {team_run_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.team_run.not_found", id=team_run_id))
     output_json = entity.output_json or {}
     output_json = entity.output_json or {}
     member_run_count = output_json.get("member_run_count")
     member_run_count = output_json.get("member_run_count")
     dry_run = output_json.get("dry_run")
     dry_run = output_json.get("dry_run")
@@ -385,7 +385,7 @@ def execute_team_run_stream(
     payload: TeamRunExecuteRequest,
     payload: TeamRunExecuteRequest,
     service: TeamApplicationService = Depends(get_team_application_service)) -> StreamingResponse:
     service: TeamApplicationService = Depends(get_team_application_service)) -> StreamingResponse:
     if service.get_team_run(team_run_id=team_run_id) is None:
     if service.get_team_run(team_run_id=team_run_id) is None:
-        raise HTTPException(status_code=404, detail=f"team_run not found: {team_run_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.team_run.not_found", id=team_run_id))
 
 
     def events():
     def events():
         for item in service.execute_team_run_stream(team_run_id=team_run_id, payload=payload):
         for item in service.execute_team_run_stream(team_run_id=team_run_id, payload=payload):
@@ -419,7 +419,7 @@ def execute_next_worker_task(
         dry_run=payload.dry_run if payload.dry_run is not None else settings.worker_dry_run,
         dry_run=payload.dry_run if payload.dry_run is not None else settings.worker_dry_run,
         redis_client=try_build_redis_client(settings.redis_url))
         redis_client=try_build_redis_client(settings.redis_url))
     if result is None:
     if result is None:
-        raise HTTPException(status_code=404, detail="queued team_run not found")
+        raise HTTPException(status_code=404, detail=error_detail("error.team_run.not_found_queued"))
 
 
     entity, released_lease_count = result
     entity, released_lease_count = result
     output_json = entity.output_json or {}
     output_json = entity.output_json or {}

+ 15 - 14
services/tool-service/app/api/routes.py

@@ -2,6 +2,7 @@ from datetime import datetime
 from typing import Annotated, TypeVar
 from typing import Annotated, TypeVar
 
 
 from core_domain import ServiceHealth
 from core_domain import ServiceHealth
+from core_shared import error_detail
 from fastapi import APIRouter, Depends, HTTPException, Query, Request
 from fastapi import APIRouter, Depends, HTTPException, Query, Request
 from sqlalchemy import text
 from sqlalchemy import text
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
@@ -102,7 +103,7 @@ def create_tool_binding(
     try:
     try:
         entity = service.create_tool_binding(payload)
         entity = service.create_tool_binding(payload)
     except ValueError as exc:
     except ValueError as exc:
-        raise HTTPException(status_code=422, detail=str(exc)) from exc
+        raise HTTPException(status_code=422, detail=error_detail("error.validation", message=str(exc))) from exc
     return ToolBindingResponse.from_entity(entity)
     return ToolBindingResponse.from_entity(entity)
 
 
 
 
@@ -122,7 +123,7 @@ def get_tool_binding_detail(
     service: ToolServiceDep) -> ToolBindingDetailResponse:
     service: ToolServiceDep) -> ToolBindingDetailResponse:
     result = service.get_tool_binding_detail(binding_id=binding_id)
     result = service.get_tool_binding_detail(binding_id=binding_id)
     if result is None:
     if result is None:
-        raise HTTPException(status_code=404, detail=f"tool_binding not found: {binding_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.tool_binding.not_found", id=binding_id))
 
 
     binding, tool_connection, tool_definition = result
     binding, tool_connection, tool_definition = result
     return ToolBindingDetailResponse(
     return ToolBindingDetailResponse(
@@ -155,7 +156,7 @@ def reveal_tool_credential(
     result = service.reveal_tool_credential(
     result = service.reveal_tool_credential(
         credential_id=credential_id)
         credential_id=credential_id)
     if result is None:
     if result is None:
-        raise HTTPException(status_code=404, detail=f"tool credential not found: {credential_id}")
+        raise HTTPException(status_code=404, detail=error_detail("error.tool_credential.not_found", id=credential_id))
     credential, secret_json = result
     credential, secret_json = result
     return ToolCredentialRevealResponse(
     return ToolCredentialRevealResponse(
         credential=ToolCredentialResponse.from_entity(credential),
         credential=ToolCredentialResponse.from_entity(credential),
@@ -198,7 +199,7 @@ def get_tool_contract(
     service: ToolServiceDep) -> ApiResponse[ToolDto]:
     service: ToolServiceDep) -> ApiResponse[ToolDto]:
     entity = service.get_tool_definition_from_contract(payload)
     entity = service.get_tool_definition_from_contract(payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"tool not found: {payload.toolId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.tool.not_found", id=payload.toolId))
     return ok(ToolDto.from_entity(entity))
     return ok(ToolDto.from_entity(entity))
 
 
 
 
@@ -208,7 +209,7 @@ def update_tool_contract(
     service: ToolServiceDep) -> ApiResponse[ToolDto]:
     service: ToolServiceDep) -> ApiResponse[ToolDto]:
     entity = service.update_tool_definition_from_contract(payload)
     entity = service.update_tool_definition_from_contract(payload)
     if entity is None:
     if entity is None:
-        raise HTTPException(status_code=404, detail=f"tool not found: {payload.toolId}")
+        raise HTTPException(status_code=404, detail=error_detail("error.tool.not_found", id=payload.toolId))
     return ok(ToolDto.from_entity(entity))
     return ok(ToolDto.from_entity(entity))
 
 
 
 
@@ -250,7 +251,7 @@ def get_tool_connection_contract(
     if entity is None:
     if entity is None:
         raise HTTPException(
         raise HTTPException(
             status_code=404,
             status_code=404,
-            detail=f"tool connection not found: {payload.connectionId}")
+            detail=error_detail("error.tool_connection.not_found", id=payload.connectionId))
     return ok(ToolConnectionDto.from_entity(entity))
     return ok(ToolConnectionDto.from_entity(entity))
 
 
 
 
@@ -262,7 +263,7 @@ def update_tool_connection_contract(
     if entity is None:
     if entity is None:
         raise HTTPException(
         raise HTTPException(
             status_code=404,
             status_code=404,
-            detail=f"tool connection not found: {payload.connectionId}")
+            detail=error_detail("error.tool_connection.not_found", id=payload.connectionId))
     return ok(ToolConnectionDto.from_entity(entity))
     return ok(ToolConnectionDto.from_entity(entity))
 
 
 
 
@@ -290,7 +291,7 @@ def discover_mcp_server_contract(
     if connection is None:
     if connection is None:
         raise HTTPException(
         raise HTTPException(
             status_code=404,
             status_code=404,
-            detail=f"tool connection not found: {payload.connectionId}")
+            detail=error_detail("error.tool_connection.not_found", id=payload.connectionId))
     return ok(ToolConnectionDto.from_entity(connection))
     return ok(ToolConnectionDto.from_entity(connection))
 
 
 
 
@@ -315,7 +316,7 @@ def create_tool_binding_contract(
     try:
     try:
         entity = service.create_tool_binding_from_contract(payload)
         entity = service.create_tool_binding_from_contract(payload)
     except ValueError as exc:
     except ValueError as exc:
-        raise HTTPException(status_code=422, detail=str(exc)) from exc
+        raise HTTPException(status_code=422, detail=error_detail("error.validation", message=str(exc))) from exc
     return ok(ToolBindingDto.from_entity(entity))
     return ok(ToolBindingDto.from_entity(entity))
 
 
 
 
@@ -327,7 +328,7 @@ def get_tool_binding_contract(
     if entity is None:
     if entity is None:
         raise HTTPException(
         raise HTTPException(
             status_code=404,
             status_code=404,
-            detail=f"tool binding not found: {payload.bindingId}")
+            detail=error_detail("error.tool_binding.not_found", id=payload.bindingId))
     return ok(ToolBindingDto.from_entity(entity))
     return ok(ToolBindingDto.from_entity(entity))
 
 
 
 
@@ -339,7 +340,7 @@ def update_tool_binding_contract(
     if entity is None:
     if entity is None:
         raise HTTPException(
         raise HTTPException(
             status_code=404,
             status_code=404,
-            detail=f"tool binding not found: {payload.bindingId}")
+            detail=error_detail("error.tool_binding.not_found", id=payload.bindingId))
     return ok(ToolBindingDto.from_entity(entity))
     return ok(ToolBindingDto.from_entity(entity))
 
 
 
 
@@ -388,7 +389,7 @@ def get_tool_credential_contract(
     if entity is None:
     if entity is None:
         raise HTTPException(
         raise HTTPException(
             status_code=404,
             status_code=404,
-            detail=f"tool credential not found: {payload.credentialId}")
+            detail=error_detail("error.tool_credential.not_found", id=payload.credentialId))
     return ok(ToolCredentialDto.from_entity(entity))
     return ok(ToolCredentialDto.from_entity(entity))
 
 
 
 
@@ -400,7 +401,7 @@ def update_tool_credential_contract(
     if entity is None:
     if entity is None:
         raise HTTPException(
         raise HTTPException(
             status_code=404,
             status_code=404,
-            detail=f"tool credential not found: {payload.credentialId}")
+            detail=error_detail("error.tool_credential.not_found", id=payload.credentialId))
     return ok(ToolCredentialDto.from_entity(entity))
     return ok(ToolCredentialDto.from_entity(entity))
 
 
 
 
@@ -421,5 +422,5 @@ def reveal_tool_credential_contract(
     if result is None:
     if result is None:
         raise HTTPException(
         raise HTTPException(
             status_code=404,
             status_code=404,
-            detail=f"tool credential not found: {payload.credentialId}")
+            detail=error_detail("error.tool_credential.not_found", id=payload.credentialId))
     return ok(result)
     return ok(result)

+ 56 - 46
tests/test_team_service.py

@@ -1,5 +1,6 @@
 from pathlib import Path
 from pathlib import Path
 from datetime import datetime
 from datetime import datetime
+from unittest.mock import MagicMock
 
 
 from tests.conftest import (
 from tests.conftest import (
     build_fastapi_test_client,
     build_fastapi_test_client,
@@ -196,7 +197,6 @@ def test_team_service_compacts_member_context_between_agent_calls() -> None:
 
 
 def _build_service_with_mock_agent() -> tuple:
 def _build_service_with_mock_agent() -> tuple:
     prepare_known_service_import("team-service")
     prepare_known_service_import("team-service")
-    from unittest.mock import MagicMock
     from app.application.services import TeamApplicationService, TeamMemberRunResult
     from app.application.services import TeamApplicationService, TeamMemberRunResult
     from core_domain import AgentRunContract, TeamMemberContract
     from core_domain import AgentRunContract, TeamMemberContract
 
 
@@ -208,7 +208,7 @@ def _build_service_with_mock_agent() -> tuple:
             run=AgentRunContract(
             run=AgentRunContract(
                 id=f"run_{member.member_key}",
                 id=f"run_{member.member_key}",
                 agent_id=member.agent_id,
                 agent_id=member.agent_id,
-                agent_config_id=member.agent_config_id,
+                agent_config_id=member.agent_config_id or "default_config",
                 output_text=text,
                 output_text=text,
                 output_json={},
                 output_json={},
                 status="completed",
                 status="completed",
@@ -236,11 +236,36 @@ def _build_service_with_mock_agent() -> tuple:
         team_config_repository=None,
         team_config_repository=None,
         team_run_repository=None,
         team_run_repository=None,
         agent_client=mock_client)
         agent_client=mock_client)
-    return service, call_log, track_execute
+    return service, call_log, track_execute, make_member_result
+
+
+def _mock_team_run(**overrides):
+    run = MagicMock()
+    run.id = "run_test"
+    run.team_id = "team_test"
+    run.team_config_id = "config_test"
+    run.session_id = None
+    run.input_text = "test input"
+    run.input_json = None
+    for k, v in overrides.items():
+        setattr(run, k, v)
+    return run
+
+
+def _mock_team_config(**overrides):
+    config = MagicMock()
+    config.id = "config_test"
+    config.team_id = "team_test"
+    config.coordination_mode = "supervisor"
+    config.objective = "test objective"
+    config.policy_json = {}
+    for k, v in overrides.items():
+        setattr(config, k, v)
+    return config
 
 
 
 
 def test_supervisor_mode_executes_lead_first_then_others() -> None:
 def test_supervisor_mode_executes_lead_first_then_others() -> None:
-    service, call_log, track_execute = _build_service_with_mock_agent()
+    service, call_log, track_execute, _ = _build_service_with_mock_agent()
     from unittest.mock import patch
     from unittest.mock import patch
     from core_domain import TeamMemberContract
     from core_domain import TeamMemberContract
 
 
@@ -250,15 +275,13 @@ def test_supervisor_mode_executes_lead_first_then_others() -> None:
         TeamMemberContract(member_key="worker_2", agent_id="a3", role="reviewer"),
         TeamMemberContract(member_key="worker_2", agent_id="a3", role="reviewer"),
     ]
     ]
 
 
-    team_config = type("C", (), {
-        "coordination_mode": "supervisor",
-        "objective": "test",
-        "policy_json": {"supervisor_synthesis": True},
-    })()
+    team_config = _mock_team_config(
+        coordination_mode="supervisor",
+        policy_json={"supervisor_synthesis": True})
 
 
     with patch.object(service, "_execute_single_member", side_effect=track_execute):
     with patch.object(service, "_execute_single_member", side_effect=track_execute):
         results = service._execute_members(
         results = service._execute_members(
-            team_run=MagicMock(), team_config=team_config,
+            team_run=_mock_team_run(), team_config=team_config,
             members=members, worker_key=None, dry_run=False)
             members=members, worker_key=None, dry_run=False)
 
 
     # lead runs first, then workers, then synthesis = 4 executions
     # lead runs first, then workers, then synthesis = 4 executions
@@ -271,7 +294,7 @@ def test_supervisor_mode_executes_lead_first_then_others() -> None:
 
 
 
 
 def test_pipeline_mode_chains_single_prior_output() -> None:
 def test_pipeline_mode_chains_single_prior_output() -> None:
-    service, call_log, track_execute = _build_service_with_mock_agent()
+    service, call_log, track_execute, _ = _build_service_with_mock_agent()
     from unittest.mock import patch
     from unittest.mock import patch
     from core_domain import TeamMemberContract
     from core_domain import TeamMemberContract
 
 
@@ -281,15 +304,13 @@ def test_pipeline_mode_chains_single_prior_output() -> None:
         TeamMemberContract(member_key="m3", agent_id="a3", role="reviewer"),
         TeamMemberContract(member_key="m3", agent_id="a3", role="reviewer"),
     ]
     ]
 
 
-    team_config = type("C", (), {
-        "coordination_mode": "pipeline",
-        "objective": "test",
-        "policy_json": {},
-    })()
+    team_config = _mock_team_config(
+        coordination_mode="pipeline",
+        policy_json={})
 
 
     with patch.object(service, "_execute_single_member", side_effect=track_execute):
     with patch.object(service, "_execute_single_member", side_effect=track_execute):
         results = service._execute_members(
         results = service._execute_members(
-            team_run=MagicMock(), team_config=team_config,
+            team_run=_mock_team_run(), team_config=team_config,
             members=members, worker_key=None, dry_run=False)
             members=members, worker_key=None, dry_run=False)
 
 
     assert len(results) == 3
     assert len(results) == 3
@@ -300,7 +321,7 @@ def test_pipeline_mode_chains_single_prior_output() -> None:
 
 
 
 
 def test_debate_mode_executes_multiple_rounds() -> None:
 def test_debate_mode_executes_multiple_rounds() -> None:
-    service, call_log, track_execute = _build_service_with_mock_agent()
+    service, call_log, track_execute, _ = _build_service_with_mock_agent()
     from unittest.mock import patch
     from unittest.mock import patch
     from core_domain import TeamMemberContract
     from core_domain import TeamMemberContract
 
 
@@ -309,15 +330,13 @@ def test_debate_mode_executes_multiple_rounds() -> None:
         TeamMemberContract(member_key="m2", agent_id="a2", role="reviewer"),
         TeamMemberContract(member_key="m2", agent_id="a2", role="reviewer"),
     ]
     ]
 
 
-    team_config = type("C", (), {
-        "coordination_mode": "debate",
-        "objective": "test",
-        "policy_json": {"max_rounds": 3},
-    })()
+    team_config = _mock_team_config(
+        coordination_mode="debate",
+        policy_json={"max_rounds": 3})
 
 
     with patch.object(service, "_execute_single_member", side_effect=track_execute):
     with patch.object(service, "_execute_single_member", side_effect=track_execute):
         results = service._execute_members(
         results = service._execute_members(
-            team_run=MagicMock(), team_config=team_config,
+            team_run=_mock_team_run(), team_config=team_config,
             members=members, worker_key=None, dry_run=False)
             members=members, worker_key=None, dry_run=False)
 
 
     # 2 members x 3 rounds = 6 executions, final_results = last round
     # 2 members x 3 rounds = 6 executions, final_results = last round
@@ -334,8 +353,8 @@ def test_debate_mode_executes_multiple_rounds() -> None:
 
 
 
 
 def test_failure_mode_continue_allows_partial_failure() -> None:
 def test_failure_mode_continue_allows_partial_failure() -> None:
-    service, call_log, track_execute = _build_service_with_mock_agent()
-    from unittest.mock import patch, MagicMock
+    service, call_log, _, make_member_result = _build_service_with_mock_agent()
+    from unittest.mock import patch
     from core_domain import TeamMemberContract, AgentRunContract
     from core_domain import TeamMemberContract, AgentRunContract
 
 
     members = [
     members = [
@@ -343,11 +362,9 @@ def test_failure_mode_continue_allows_partial_failure() -> None:
         TeamMemberContract(member_key="m2", agent_id="a2", role="executor"),
         TeamMemberContract(member_key="m2", agent_id="a2", role="executor"),
     ]
     ]
 
 
-    team_config = type("C", (), {
-        "coordination_mode": "supervisor",
-        "objective": "test",
-        "policy_json": {"failure_mode": "continue_with_warning"},
-    })()
+    team_config = _mock_team_config(
+        coordination_mode="supervisor",
+        policy_json={"failure_mode": "continue_with_warning"})
 
 
     call_count = 0
     call_count = 0
 
 
@@ -355,26 +372,19 @@ def test_failure_mode_continue_allows_partial_failure() -> None:
         nonlocal call_count
         nonlocal call_count
         call_count += 1
         call_count += 1
         if member.member_key == "m1":
         if member.member_key == "m1":
+            from app.application.services import TeamMemberRunResult
             return TeamMemberRunResult(
             return TeamMemberRunResult(
                 member=member,
                 member=member,
                 run=AgentRunContract(
                 run=AgentRunContract(
-                    id="run_fail", agent_id="a1",
+                    id="run_fail", agent_id="a1", agent_config_id="c1",
                     status="failed", error_code="test_error",
                     status="failed", error_code="test_error",
                     error_message="boom",
                     error_message="boom",
                     created_time=datetime.utcnow()))
                     created_time=datetime.utcnow()))
         return make_member_result(member, f"output_{member.member_key}")
         return make_member_result(member, f"output_{member.member_key}")
 
 
-    def make_member_result(member, text):
-        return TeamMemberRunResult(
-            member=member,
-            run=AgentRunContract(
-                id=f"run_{member.member_key}", agent_id=member.agent_id,
-                output_text=text, output_json={},
-                status="completed", created_time=datetime.utcnow()))
-
     with patch.object(service, "_execute_single_member", side_effect=track_with_failure):
     with patch.object(service, "_execute_single_member", side_effect=track_with_failure):
         results = service._execute_members(
         results = service._execute_members(
-            team_run=MagicMock(), team_config=team_config,
+            team_run=_mock_team_run(), team_config=team_config,
             members=members, worker_key=None, dry_run=False)
             members=members, worker_key=None, dry_run=False)
 
 
     # Both members executed despite first failing
     # Both members executed despite first failing
@@ -383,16 +393,16 @@ def test_failure_mode_continue_allows_partial_failure() -> None:
 
 
 
 
 def test_read_max_rounds_and_failure_mode_helpers() -> None:
 def test_read_max_rounds_and_failure_mode_helpers() -> None:
-    service, _, _ = _build_service_with_mock_agent()
+    service, _, _, _ = _build_service_with_mock_agent()
 
 
-    config_default = type("C", (), {"policy_json": {}})()
+    config_default = _mock_team_config(policy_json={})
     assert service._read_max_rounds(config_default) == 3
     assert service._read_max_rounds(config_default) == 3
     assert service._read_failure_mode(config_default) == "stop_on_critical"
     assert service._read_failure_mode(config_default) == "stop_on_critical"
 
 
-    config_custom = type("C", (), {"policy_json": {
-        "max_rounds": 5, "failure_mode": "continue_with_warning"}})()
+    config_custom = _mock_team_config(policy_json={
+        "max_rounds": 5, "failure_mode": "continue_with_warning"})
     assert service._read_max_rounds(config_custom) == 5
     assert service._read_max_rounds(config_custom) == 5
     assert service._read_failure_mode(config_custom) == "continue_with_warning"
     assert service._read_failure_mode(config_custom) == "continue_with_warning"
 
 
-    config_clamped = type("C", (), {"policy_json": {"max_rounds": 50}})()
+    config_clamped = _mock_team_config(policy_json={"max_rounds": 50})
     assert service._read_max_rounds(config_clamped) == 20
     assert service._read_max_rounds(config_clamped) == 20

+ 31 - 0
web/src/api/errors.ts

@@ -0,0 +1,31 @@
+import i18n from "@/i18n";
+
+/**
+ * Extract a translated error message from an API error response.
+ *
+ * Backend responses use:
+ *   { detail: { code: "error.xxx", message: "English fallback" } }
+ * Legacy format:
+ *   { detail: "plain string" }
+ */
+export function translateApiError(error: unknown): string {
+  if (error && typeof error === "object" && "response" in error) {
+    const resp = (error as { response?: { data?: { detail?: unknown } } }).response;
+    const detail = resp?.data?.detail;
+
+    if (typeof detail === "object" && detail !== null && "code" in detail) {
+      const { code, message } = detail as { code: string; message: string };
+      return i18n.t(`errors.apiErrors.${code}`, message);
+    }
+
+    if (typeof detail === "string") {
+      return i18n.t(`errors.apiErrors.${detail}`, detail);
+    }
+  }
+
+  if (error instanceof Error) {
+    return error.message;
+  }
+
+  return i18n.t("errors.somethingBroke");
+}

+ 1 - 1
web/src/components/shared/LoadingSpinner.tsx

@@ -1,4 +1,4 @@
-export function LoadingSpinner({ label = "Loading" }: { label?: string }) {
+export function LoadingSpinner({ label }: { label: string }) {
   return (
   return (
     <div className="flex min-h-64 items-center justify-center gap-3 text-sm text-muted-foreground">
     <div className="flex min-h-64 items-center justify-center gap-3 text-sm text-muted-foreground">
       <span className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
       <span className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />

+ 1 - 1
web/src/components/shared/SearchInput.tsx

@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
 export function SearchInput({
 export function SearchInput({
   value,
   value,
   onChange,
   onChange,
-  placeholder = "Search",
+  placeholder,
   className,
   className,
 }: {
 }: {
   value: string;
   value: string;

+ 59 - 3
web/src/locales/en.json

@@ -70,7 +70,11 @@
     "notSet": "Not set",
     "notSet": "Not set",
     "configured": "Configured",
     "configured": "Configured",
     "collapse": "Collapse",
     "collapse": "Collapse",
-    "expand": "Expand"
+    "expand": "Expand",
+    "format": {
+      "json": "JSON",
+      "markdown": "Markdown"
+    }
   },
   },
   "status": {
   "status": {
     "unknown": "Unknown",
     "unknown": "Unknown",
@@ -916,7 +920,7 @@
     "title": "Teams",
     "title": "Teams",
     "description": "Manage multi-agent teams: configure members, policies, and collaborative runs.",
     "description": "Manage multi-agent teams: configure members, policies, and collaborative runs.",
     "newTeam": "New Team",
     "newTeam": "New Team",
-    "searchPlaceholder": "Search teams...",
+    "searchPlaceholder": "Search by name, type, or description",
     "searchByNameType": "Search by name, id, type, or description",
     "searchByNameType": "Search by name, id, type, or description",
     "teamsShown": "of {{count}} teams shown",
     "teamsShown": "of {{count}} teams shown",
     "allStatuses": "All statuses",
     "allStatuses": "All statuses",
@@ -1223,7 +1227,57 @@
     "failedToSave": "Failed to save",
     "failedToSave": "Failed to save",
     "unableToLoadData": "Unable to load data",
     "unableToLoadData": "Unable to load data",
     "checkGatewayConnection": "Check the gateway connection and credentials.",
     "checkGatewayConnection": "Check the gateway connection and credentials.",
-    "somethingBroke": "Something broke"
+    "somethingBroke": "Something broke",
+    "apiErrors": {
+      "error.validation": "Validation error",
+      "error.auth.missing_header": "Missing authorization header",
+      "error.auth.invalid_header": "Invalid authorization header",
+      "error.auth.invalid_credentials": "Invalid username or password",
+      "error.auth.invalid_token": "Invalid token",
+      "error.auth.user_not_found": "User not found",
+      "error.auth.missing_token": "Authentication required",
+      "error.api_key.invalid": "Invalid API key",
+      "error.api_key.expired": "API key has expired",
+      "error.api_key.not_found": "API key not found",
+      "error.api_key.unauthorized": "API key does not belong to this app",
+      "error.app.not_found": "App not found",
+      "error.app.not_published": "App is not published",
+      "error.app.code_exists": "App code already exists",
+      "error.session.not_found": "Session not found",
+      "error.session.runtime_not_configured": "Session has no configured target",
+      "error.run_request.not_found": "Run request not found",
+      "error.agent.not_found": "Agent not found",
+      "error.agent_run.not_found": "Agent run not found",
+      "error.agent_run.not_found_queued": "Queued agent run not found",
+      "error.model.not_found": "Model not found",
+      "error.provider.not_found": "Provider not found",
+      "error.team.not_found": "Team not found",
+      "error.team_config.not_found": "Team config not found",
+      "error.team_run.not_found": "Team run not found",
+      "error.team_run.not_found_queued": "Queued team run not found",
+      "error.tool.not_found": "Tool not found",
+      "error.tool_binding.not_found": "Tool binding not found",
+      "error.tool_connection.not_found": "Tool connection not found",
+      "error.tool_credential.not_found": "Tool credential not found",
+      "error.skill.not_found": "Skill not found",
+      "error.skill_installation.not_found": "Skill installation not found",
+      "error.skill_run.not_found": "Skill run not found",
+      "error.knowledge_base.not_found": "Knowledge base not found",
+      "error.knowledge_document.not_found": "Document not found",
+      "error.knowledge_index_job.not_found": "Index job not found",
+      "error.knowledge_chunk.not_found": "Knowledge chunk not found",
+      "error.knowledge.indexing_failed": "Knowledge indexing failed",
+      "error.storage.unavailable": "Object storage unavailable",
+      "error.memory.not_found": "Memory not found",
+      "error.human_task.not_found": "Human task not found",
+      "error.event.not_found": "Event not found",
+      "error.scheduled_job.not_found": "Scheduled job not found",
+      "error.downstream.request_failed": "Downstream service request failed",
+      "error.downstream.unexpected_response": "Service returned unexpected response",
+      "error.downstream.missing_field": "Service returned incomplete data",
+      "error.downstream.generic_failure": "Downstream service error",
+      "error.code_runner.execution_failed": "Code execution failed"
+    }
   },
   },
   "apps": {
   "apps": {
     "title": "Apps",
     "title": "Apps",
@@ -1268,6 +1322,7 @@
     "createKey": "Create Key",
     "createKey": "Create Key",
     "keyNamePlaceholder": "Production key",
     "keyNamePlaceholder": "Production key",
     "scopes": "Scopes",
     "scopes": "Scopes",
+    "apiKeyScopesPlaceholder": "app:invoke app:stream",
     "keyDisabled": "API key disabled",
     "keyDisabled": "API key disabled",
     "disableKey": "Disable",
     "disableKey": "Disable",
     "noKeys": "No API keys",
     "noKeys": "No API keys",
@@ -1333,6 +1388,7 @@
     "showAdvanced": "Show advanced options",
     "showAdvanced": "Show advanced options",
     "hideAdvanced": "Hide advanced options",
     "hideAdvanced": "Hide advanced options",
     "localChatPlaceholder": "Local Chat",
     "localChatPlaceholder": "Local Chat",
+    "modelIdPlaceholder": "gpt-4.1-mini",
     "modelTestCompleted": "Model test completed",
     "modelTestCompleted": "Model test completed",
     "providerOpenaiCompatible": "OpenAI compatible",
     "providerOpenaiCompatible": "OpenAI compatible",
     "providerOpenai": "OpenAI",
     "providerOpenai": "OpenAI",

+ 59 - 3
web/src/locales/zh.json

@@ -70,7 +70,11 @@
     "notSet": "未设置",
     "notSet": "未设置",
     "configured": "已配置",
     "configured": "已配置",
     "collapse": "收起",
     "collapse": "收起",
-    "expand": "展开"
+    "expand": "展开",
+    "format": {
+      "json": "JSON",
+      "markdown": "Markdown"
+    }
   },
   },
   "status": {
   "status": {
     "unknown": "未知",
     "unknown": "未知",
@@ -916,7 +920,7 @@
     "title": "团队",
     "title": "团队",
     "description": "管理多智能体团队:配置成员、策略和协作运行。",
     "description": "管理多智能体团队:配置成员、策略和协作运行。",
     "newTeam": "新建团队",
     "newTeam": "新建团队",
-    "searchPlaceholder": "搜索团队...",
+    "searchPlaceholder": "按名称、类型或描述搜索",
     "searchByNameType": "按名称、ID、类型或描述搜索",
     "searchByNameType": "按名称、ID、类型或描述搜索",
     "teamsShown": "已显示 {{count}} 个团队",
     "teamsShown": "已显示 {{count}} 个团队",
     "allStatuses": "全部状态",
     "allStatuses": "全部状态",
@@ -1223,7 +1227,57 @@
     "failedToSave": "保存失败",
     "failedToSave": "保存失败",
     "unableToLoadData": "无法加载数据",
     "unableToLoadData": "无法加载数据",
     "checkGatewayConnection": "请检查网关连接和凭据配置。",
     "checkGatewayConnection": "请检查网关连接和凭据配置。",
-    "somethingBroke": "页面发生错误"
+    "somethingBroke": "页面发生错误",
+    "apiErrors": {
+      "error.validation": "数据验证失败",
+      "error.auth.missing_header": "缺少授权头",
+      "error.auth.invalid_header": "授权头无效",
+      "error.auth.invalid_credentials": "用户名或密码错误",
+      "error.auth.invalid_token": "令牌无效",
+      "error.auth.user_not_found": "用户未找到",
+      "error.auth.missing_token": "缺少认证凭证",
+      "error.api_key.invalid": "API Key 无效",
+      "error.api_key.expired": "API Key 已过期",
+      "error.api_key.not_found": "API Key 未找到",
+      "error.api_key.unauthorized": "API Key 与当前应用不匹配",
+      "error.app.not_found": "应用未找到",
+      "error.app.not_published": "应用未发布",
+      "error.app.code_exists": "应用编码已存在",
+      "error.session.not_found": "会话未找到",
+      "error.session.runtime_not_configured": "会话运行目标未配置",
+      "error.run_request.not_found": "运行请求未找到",
+      "error.agent.not_found": "智能体未找到",
+      "error.agent_run.not_found": "智能体运行未找到",
+      "error.agent_run.not_found_queued": "排队的智能体运行未找到",
+      "error.model.not_found": "模型未找到",
+      "error.provider.not_found": "供应商未找到",
+      "error.team.not_found": "团队未找到",
+      "error.team_config.not_found": "团队配置未找到",
+      "error.team_run.not_found": "团队运行未找到",
+      "error.team_run.not_found_queued": "排队的团队运行未找到",
+      "error.tool.not_found": "工具未找到",
+      "error.tool_binding.not_found": "工具绑定未找到",
+      "error.tool_connection.not_found": "工具连接未找到",
+      "error.tool_credential.not_found": "工具凭证未找到",
+      "error.skill.not_found": "技能未找到",
+      "error.skill_installation.not_found": "技能安装未找到",
+      "error.skill_run.not_found": "技能运行未找到",
+      "error.knowledge_base.not_found": "知识库未找到",
+      "error.knowledge_document.not_found": "文档未找到",
+      "error.knowledge_index_job.not_found": "索引任务未找到",
+      "error.knowledge_chunk.not_found": "知识分块未找到",
+      "error.knowledge.indexing_failed": "知识索引失败",
+      "error.storage.unavailable": "对象存储不可用",
+      "error.memory.not_found": "记忆未找到",
+      "error.human_task.not_found": "人工任务未找到",
+      "error.event.not_found": "事件未找到",
+      "error.scheduled_job.not_found": "定时任务未找到",
+      "error.downstream.request_failed": "下游服务请求失败",
+      "error.downstream.unexpected_response": "服务返回了意外的响应",
+      "error.downstream.missing_field": "服务返回数据不完整",
+      "error.downstream.generic_failure": "下游服务异常",
+      "error.code_runner.execution_failed": "代码执行失败"
+    }
   },
   },
   "apps": {
   "apps": {
     "title": "应用",
     "title": "应用",
@@ -1268,6 +1322,7 @@
     "createKey": "创建 Key",
     "createKey": "创建 Key",
     "keyNamePlaceholder": "生产环境 Key",
     "keyNamePlaceholder": "生产环境 Key",
     "scopes": "权限范围",
     "scopes": "权限范围",
+    "apiKeyScopesPlaceholder": "app:invoke app:stream",
     "keyDisabled": "API Key 已禁用",
     "keyDisabled": "API Key 已禁用",
     "disableKey": "禁用",
     "disableKey": "禁用",
     "noKeys": "暂无 API Key",
     "noKeys": "暂无 API Key",
@@ -1333,6 +1388,7 @@
     "showAdvanced": "显示高级选项",
     "showAdvanced": "显示高级选项",
     "hideAdvanced": "隐藏高级选项",
     "hideAdvanced": "隐藏高级选项",
     "localChatPlaceholder": "本地对话",
     "localChatPlaceholder": "本地对话",
+    "modelIdPlaceholder": "gpt-4.1-mini",
     "modelTestCompleted": "模型测试已完成",
     "modelTestCompleted": "模型测试已完成",
     "providerOpenaiCompatible": "OpenAI 兼容",
     "providerOpenaiCompatible": "OpenAI 兼容",
     "providerOpenai": "OpenAI",
     "providerOpenai": "OpenAI",

+ 4 - 3
web/src/pages/agents/AgentListPage.tsx

@@ -8,6 +8,7 @@ import {
   Trash2,
   Trash2,
 } from "lucide-react";
 } from "lucide-react";
 import { createAgentConfig, deleteAgent, listAgentConfigs, listAgentRuns, listModels, listSkills, updateAgent } from "@/api";
 import { createAgentConfig, deleteAgent, listAgentConfigs, listAgentRuns, listModels, listSkills, updateAgent } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
 import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { EmptyState } from "@/components/shared/EmptyState";
@@ -422,7 +423,7 @@ function EditAgentDialog({
       .then((skills) => setAvailableSkills(Array.isArray(skills) ? skills : []))
       .then((skills) => setAvailableSkills(Array.isArray(skills) ? skills : []))
       .catch((err) => {
       .catch((err) => {
         setAvailableSkills([]);
         setAvailableSkills([]);
-        setSkillsError(err instanceof Error ? err.message : t("agents.failedToLoadSkills"));
+        setSkillsError(translateApiError(err));
       })
       })
       .finally(() => setSkillsLoading(false));
       .finally(() => setSkillsLoading(false));
   }, [open, agent, activeConfig]);
   }, [open, agent, activeConfig]);
@@ -634,8 +635,8 @@ function EditAgentDialog({
                 onChange={(event) => setOutputFormat(event.target.value)}
                 onChange={(event) => setOutputFormat(event.target.value)}
                 options={[
                 options={[
                   { value: "text", label: t("agents.outputText") },
                   { value: "text", label: t("agents.outputText") },
-                  { value: "json", label: "JSON" },
-                  { value: "markdown", label: "Markdown" },
+                  { value: "json", label: t("common.format.json") },
+                  { value: "markdown", label: t("common.format.markdown") },
                 ]}
                 ]}
               />
               />
             </Field>
             </Field>

+ 2 - 1
web/src/pages/agents/components/AgentRuns.tsx

@@ -2,6 +2,7 @@ import * as React from "react";
 import { useTranslation } from "react-i18next";
 import { useTranslation } from "react-i18next";
 import { Activity, AlertCircle, CheckCircle2, Clock, Loader2, Play, TerminalSquare } from "lucide-react";
 import { Activity, AlertCircle, CheckCircle2, Clock, Loader2, Play, TerminalSquare } from "lucide-react";
 import { createAgentRun, executeAgentRun } from "@/api";
 import { createAgentRun, executeAgentRun } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { StatusBadge } from "@/components/shared/StatusBadge";
 import { StatusBadge } from "@/components/shared/StatusBadge";
@@ -67,7 +68,7 @@ export function AgentRuns({
       onRunCompleted?.();
       onRunCompleted?.();
       toast.success(t("agents.testCompleted"));
       toast.success(t("agents.testCompleted"));
     } catch (err) {
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("agents.testFailed"));
+      toast.error(translateApiError(err));
     } finally {
     } finally {
       setExecuting(false);
       setExecuting(false);
     }
     }

+ 5 - 4
web/src/pages/agents/components/CreateAgentDialog.tsx

@@ -2,6 +2,7 @@
 import { useTranslation } from "react-i18next";
 import { useTranslation } from "react-i18next";
 import { Plus, Sparkles } from "lucide-react";
 import { Plus, Sparkles } from "lucide-react";
 import { createAgent, createAgentConfig, listModels, listSkills } from "@/api";
 import { createAgent, createAgentConfig, listModels, listSkills } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { Button } from "@/components/ui/button";
 import { Button } from "@/components/ui/button";
 import { Dialog } from "@/components/ui/dialog";
 import { Dialog } from "@/components/ui/dialog";
 import { Input, Textarea } from "@/components/ui/input";
 import { Input, Textarea } from "@/components/ui/input";
@@ -66,7 +67,7 @@ export function CreateAgentDialog({ onCreated }: { onCreated: () => void }) {
       .then((skills) => setAvailableSkills(Array.isArray(skills) ? skills : []))
       .then((skills) => setAvailableSkills(Array.isArray(skills) ? skills : []))
       .catch((err) => {
       .catch((err) => {
         setAvailableSkills([]);
         setAvailableSkills([]);
-        setSkillsError(err instanceof Error ? err.message : t("agents.failedToLoadSkills"));
+        setSkillsError(translateApiError(err));
       })
       })
       .finally(() => setSkillsLoading(false));
       .finally(() => setSkillsLoading(false));
   }, [open]);
   }, [open]);
@@ -138,7 +139,7 @@ export function CreateAgentDialog({ onCreated }: { onCreated: () => void }) {
       reset();
       reset();
       onCreated();
       onCreated();
     } catch (err) {
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("errors.failedToCreate"));
+      toast.error(translateApiError(err));
     } finally {
     } finally {
       setSubmitting(false);
       setSubmitting(false);
     }
     }
@@ -268,8 +269,8 @@ export function CreateAgentDialog({ onCreated }: { onCreated: () => void }) {
                   onChange={(event) => setOutputFormat(event.target.value)}
                   onChange={(event) => setOutputFormat(event.target.value)}
                   options={[
                   options={[
                     { value: "text", label: t("agents.outputText") },
                     { value: "text", label: t("agents.outputText") },
-                    { value: "json", label: "JSON" },
-                    { value: "markdown", label: "Markdown" },
+                    { value: "json", label: t("common.format.json") },
+                    { value: "markdown", label: t("common.format.markdown") },
                   ]}
                   ]}
                 />
                 />
               </Field>
               </Field>

+ 2 - 1
web/src/pages/apps/AppsPage.tsx

@@ -8,6 +8,7 @@ import {
   SlidersHorizontal,
   SlidersHorizontal,
 } from "lucide-react";
 } from "lucide-react";
 import { listApps } from "@/api/apps";
 import { listApps } from "@/api/apps";
+import { translateApiError } from "@/api/errors";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
@@ -54,7 +55,7 @@ export function AppsPage() {
       setApps(data);
       setApps(data);
       setSelectedAppId((current) => current ?? data[0]?.id);
       setSelectedAppId((current) => current ?? data[0]?.id);
     } catch (err) {
     } catch (err) {
-      setError(err instanceof Error ? err.message : t("errors.failedToLoad"));
+      setError(translateApiError(err));
     } finally {
     } finally {
       setLoading(false);
       setLoading(false);
     }
     }

+ 1 - 1
web/src/pages/apps/components/AppApiKeysPanel.tsx

@@ -156,7 +156,7 @@ function CreateAppApiKeyDialog({
           </div>
           </div>
           <div>
           <div>
             <label className="mb-1 block text-xs font-medium text-muted-foreground">{t("apps.scopes")}</label>
             <label className="mb-1 block text-xs font-medium text-muted-foreground">{t("apps.scopes")}</label>
-            <Input value={scopes} onChange={(e) => setScopes(e.target.value)} placeholder="app:invoke app:stream" />
+            <Input value={scopes} onChange={(e) => setScopes(e.target.value)} placeholder={t("apps.apiKeyScopesPlaceholder")} />
           </div>
           </div>
         </div>
         </div>
         <div className="flex justify-end gap-2">
         <div className="flex justify-end gap-2">

+ 2 - 1
web/src/pages/apps/components/CreateAppDialog.tsx

@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
 import { listAgents } from "@/api/agents";
 import { listAgents } from "@/api/agents";
 import { listTeams } from "@/api";
 import { listTeams } from "@/api";
 import { createApp, createAppApiKey } from "@/api/apps";
 import { createApp, createAppApiKey } from "@/api/apps";
+import { translateApiError } from "@/api/errors";
 import { Button } from "@/components/ui/button";
 import { Button } from "@/components/ui/button";
 import { Dialog } from "@/components/ui/dialog";
 import { Dialog } from "@/components/ui/dialog";
 import { Input } from "@/components/ui/input";
 import { Input } from "@/components/ui/input";
@@ -80,7 +81,7 @@ export function CreateAppDialog({
       setStep("key");
       setStep("key");
       onCreated(app);
       onCreated(app);
     } catch (err) {
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("errors.failedToCreate"));
+      toast.error(translateApiError(err));
     } finally {
     } finally {
       setSaving(false);
       setSaving(false);
     }
     }

+ 20 - 24
web/src/pages/knowledge/KnowledgePage.tsx

@@ -40,6 +40,7 @@ import {
   updateKnowledgeSettings,
   updateKnowledgeSettings,
   updateKnowledgeBaseStatus,
   updateKnowledgeBaseStatus,
 } from "@/api";
 } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
@@ -60,24 +61,9 @@ import { formatDateTime } from "@/lib/utils";
 import type { JSONObject, KnowledgeBase, KnowledgeChunk, KnowledgeDocument, KnowledgeDocumentIngestResponse, KnowledgeDocumentParseResponse, KnowledgeIndexJob, ModelDefinition, SearchResult } from "@/types";
 import type { JSONObject, KnowledgeBase, KnowledgeChunk, KnowledgeDocument, KnowledgeDocumentIngestResponse, KnowledgeDocumentParseResponse, KnowledgeIndexJob, ModelDefinition, SearchResult } from "@/types";
 import type { KnowledgeSettingsPayload } from "@/api/knowledge";
 import type { KnowledgeSettingsPayload } from "@/api/knowledge";
 
 
-const documentStatusOptions = [
-  { value: "all", label: "All statuses" },
-  { value: "queued", label: "Queued" },
-  { value: "indexing", label: "Indexing" },
-  { value: "indexed", label: "Indexed" },
-  { value: "draft", label: "Draft" },
-  { value: "failed", label: "Failed" },
-  { value: "archived", label: "Archived" },
-];
+const documentStatusValues = ["all", "queued", "indexing", "indexed", "draft", "failed", "archived"] as const;
 
 
-const sourceTypeOptions = [
-  { value: "all", label: "All sources" },
-  { value: "text", label: "Text" },
-  { value: "markdown", label: "Markdown" },
-  { value: "json", label: "Structured" },
-  { value: "html", label: "HTML" },
-  { value: "pdf", label: "PDF" },
-];
+const sourceTypeValues = ["all", "text", "markdown", "json", "html", "pdf"] as const;
 
 
 type KnowledgeJob = {
 type KnowledgeJob = {
   id: string;
   id: string;
@@ -206,6 +192,16 @@ const capabilityGroups = [
 export function KnowledgePage() {
 export function KnowledgePage() {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const navigate = useNavigate();
   const navigate = useNavigate();
+
+  const documentStatusOptions = documentStatusValues.map((value) => ({
+    value,
+    label: t(`knowledge.statusLabels.${value}`),
+  }));
+
+  const sourceTypeOptions = sourceTypeValues.map((value) => ({
+    value,
+    label: t(`knowledge.sourceLabels.${value}`),
+  }));
   const { section: sectionParam } = useParams();
   const { section: sectionParam } = useParams();
   const section = knowledgeSections.some((item) => item.value === sectionParam) ? sectionParam ?? "overview" : "overview";
   const section = knowledgeSections.some((item) => item.value === sectionParam) ? sectionParam ?? "overview" : "overview";
   const [bases, setBases] = React.useState<KnowledgeBase[]>([]);
   const [bases, setBases] = React.useState<KnowledgeBase[]>([]);
@@ -270,7 +266,7 @@ export function KnowledgePage() {
       setBases(data);
       setBases(data);
       setSelectedBaseId((current) => current ?? data[0]?.id);
       setSelectedBaseId((current) => current ?? data[0]?.id);
     } catch (err) {
     } catch (err) {
-      setError(err instanceof Error ? err.message : t("knowledge.failedToLoadBases"));
+      setError(translateApiError(err));
     } finally {
     } finally {
       setLoading(false);
       setLoading(false);
     }
     }
@@ -381,7 +377,7 @@ export function KnowledgePage() {
       if (section !== "playground") navigate("/knowledge/playground");
       if (section !== "playground") navigate("/knowledge/playground");
       toast.info(data.length ? t("knowledge.searchResults", { count: data.length }) : t("knowledge.noMatchingChunks"));
       toast.info(data.length ? t("knowledge.searchResults", { count: data.length }) : t("knowledge.noMatchingChunks"));
     } catch (err) {
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("knowledge.searchFailed"));
+      toast.error(translateApiError(err));
     } finally {
     } finally {
       setSearching(false);
       setSearching(false);
     }
     }
@@ -578,7 +574,7 @@ export function KnowledgePage() {
             <CardContent className="space-y-4">
             <CardContent className="space-y-4">
               <form className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_170px_140px] 2xl:grid-cols-[minmax(0,1fr)_170px_140px_auto]" onSubmit={(event) => void runSearch(event)}>
               <form className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_170px_140px] 2xl:grid-cols-[minmax(0,1fr)_170px_140px_auto]" onSubmit={(event) => void runSearch(event)}>
                 <Input aria-label={t("knowledge.query")} value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("knowledge.askRetrievalQuestion")} />
                 <Input aria-label={t("knowledge.query")} value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("knowledge.askRetrievalQuestion")} />
-                <Select aria-label={t("knowledge.searchSourceFilter")} value={sourceType} onChange={(event) => setSourceType(event.target.value)} options={sourceTypeOptions.map((option) => ({ ...option, label: t(`knowledge.sourceLabels.${option.value}`) }))} />
+                <Select aria-label={t("knowledge.searchSourceFilter")} value={sourceType} onChange={(event) => setSourceType(event.target.value)} options={sourceTypeOptions} />
                 <Select aria-label="Top K" value={topK} onChange={(event) => setTopK(event.target.value)} options={[3, 5, 10, 20].map((value) => ({ value: String(value), label: `Top ${value}` }))} />
                 <Select aria-label="Top K" value={topK} onChange={(event) => setTopK(event.target.value)} options={[3, 5, 10, 20].map((value) => ({ value: String(value), label: `Top ${value}` }))} />
                 <Button className="xl:col-span-3 2xl:col-span-1" disabled={!activeBaseIds.length || !query.trim() || searching}><Search className="h-4 w-4" /> {searching ? t("knowledge.searching") : t("knowledge.runSearch")}</Button>
                 <Button className="xl:col-span-3 2xl:col-span-1" disabled={!activeBaseIds.length || !query.trim() || searching}><Search className="h-4 w-4" /> {searching ? t("knowledge.searching") : t("knowledge.runSearch")}</Button>
               </form>
               </form>
@@ -858,7 +854,7 @@ function DocumentsPanel({
           </div>
           </div>
           <div className="grid w-full min-w-0 gap-3 md:grid-cols-[minmax(0,1fr)_180px] 2xl:w-[720px] 2xl:grid-cols-[minmax(0,1fr)_180px_auto]">
           <div className="grid w-full min-w-0 gap-3 md:grid-cols-[minmax(0,1fr)_180px] 2xl:w-[720px] 2xl:grid-cols-[minmax(0,1fr)_180px_auto]">
             <SearchInput className="w-full sm:w-full" value={documentSearch} onChange={onSearch} placeholder={t("knowledge.searchDocuments")} />
             <SearchInput className="w-full sm:w-full" value={documentSearch} onChange={onSearch} placeholder={t("knowledge.searchDocuments")} />
-            <Select aria-label={t("common.status")} value={documentStatus} onChange={(event) => onStatus(event.target.value)} options={documentStatusOptions.map((option) => ({ ...option, label: t(`knowledge.statusLabels.${option.value}`) }))} />
+            <Select aria-label={t("common.status")} value={documentStatus} onChange={(event) => onStatus(event.target.value)} options={documentStatusValues.map((value) => ({ value, label: t(`knowledge.statusLabels.${value}`) }))} />
             <Button className="md:col-span-2 2xl:col-span-1" variant="secondary" disabled={!canAdd} onClick={onAdd}><FilePlus className="h-4 w-4" /> {t("knowledge.addDocument")}</Button>
             <Button className="md:col-span-2 2xl:col-span-1" variant="secondary" disabled={!canAdd} onClick={onAdd}><FilePlus className="h-4 w-4" /> {t("knowledge.addDocument")}</Button>
           </div>
           </div>
         </div>
         </div>
@@ -1536,7 +1532,7 @@ function CreateKnowledgeDocumentDialog({
       setParsePreview(parsed);
       setParsePreview(parsed);
       toast.success(t("knowledge.parsePreviewReady"));
       toast.success(t("knowledge.parsePreviewReady"));
     } catch (err) {
     } catch (err) {
-      setError(err instanceof Error ? err.message : t("knowledge.parseFailed"));
+      setError(translateApiError(err));
     } finally {
     } finally {
       setParsing(false);
       setParsing(false);
     }
     }
@@ -1564,7 +1560,7 @@ function CreateKnowledgeDocumentDialog({
       setParsePreview(undefined);
       setParsePreview(undefined);
       onCreated(ingest);
       onCreated(ingest);
     } catch (err) {
     } catch (err) {
-      setError(err instanceof Error ? err.message : t("knowledge.documentIngestFailed"));
+      setError(translateApiError(err));
     } finally {
     } finally {
       setSubmitting(false);
       setSubmitting(false);
     }
     }
@@ -1583,7 +1579,7 @@ function CreateKnowledgeDocumentDialog({
         <div className="grid gap-3 lg:grid-cols-2">
         <div className="grid gap-3 lg:grid-cols-2">
           <Field label={t("knowledge.addDocumentTitle")}><Input required value={form.title} onChange={(event) => setForm({ ...form, title: event.target.value })} /></Field>
           <Field label={t("knowledge.addDocumentTitle")}><Input required value={form.title} onChange={(event) => setForm({ ...form, title: event.target.value })} /></Field>
           <Field label={t("knowledge.sourceType")}>
           <Field label={t("knowledge.sourceType")}>
-            <Select value={form.sourceType} onChange={(event) => setForm({ ...form, sourceType: event.target.value })} options={sourceTypeOptions.filter((option) => option.value !== "all").map((option) => ({ ...option, label: t(`knowledge.sourceLabels.${option.value}`) }))} />
+            <Select value={form.sourceType} onChange={(event) => setForm({ ...form, sourceType: event.target.value })} options={sourceTypeValues.filter((value) => value !== "all").map((value) => ({ value, label: t(`knowledge.sourceLabels.${value}`) }))} />
           </Field>
           </Field>
         </div>
         </div>
         <Field label={t("knowledge.sourceUri")}><Input value={form.sourceUri} onChange={(event) => setForm({ ...form, sourceUri: event.target.value })} placeholder="https://docs.example.com/page" /></Field>
         <Field label={t("knowledge.sourceUri")}><Input value={form.sourceUri} onChange={(event) => setForm({ ...form, sourceUri: event.target.value })} placeholder="https://docs.example.com/page" /></Field>

+ 2 - 1
web/src/pages/memories/MemoryPage.tsx

@@ -7,6 +7,7 @@ import {
   RefreshCw,
   RefreshCw,
 } from "lucide-react";
 } from "lucide-react";
 import { listAllMemories } from "@/api";
 import { listAllMemories } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
@@ -61,7 +62,7 @@ export function MemoryPage() {
       setMemories(results);
       setMemories(results);
       setSelectedId((current) => current ?? results[0]?.id);
       setSelectedId((current) => current ?? results[0]?.id);
     } catch (err) {
     } catch (err) {
-      setError(err instanceof Error ? err.message : t("memories.failedToLoad"));
+      setError(translateApiError(err));
     } finally {
     } finally {
       setLoading(false);
       setLoading(false);
     }
     }

+ 11 - 10
web/src/pages/models/ModelsPage.tsx

@@ -25,6 +25,7 @@ import {
   updateModelProvider,
   updateModelProvider,
   updateModel,
   updateModel,
 } from "@/api";
 } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
@@ -153,7 +154,7 @@ export function ModelsPage() {
       setModels(await listModels());
       setModels(await listModels());
       setModelProviders(await listModelProviders().catch(() => []));
       setModelProviders(await listModelProviders().catch(() => []));
     } catch (err) {
     } catch (err) {
-      setError(err instanceof Error ? err.message : t("models.failedToLoad"));
+      setError(translateApiError(err));
     } finally {
     } finally {
       setLoading(false);
       setLoading(false);
     }
     }
@@ -219,7 +220,7 @@ export function ModelsPage() {
       setEditOpen(false);
       setEditOpen(false);
       toast.success(t("models.modelSaved"));
       toast.success(t("models.modelSaved"));
     } catch (err) {
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("models.failedToSave"));
+      toast.error(translateApiError(err));
     } finally {
     } finally {
       setSaving(false);
       setSaving(false);
     }
     }
@@ -231,7 +232,7 @@ export function ModelsPage() {
       setModels((current) => current.filter((item) => item.id !== model.id));
       setModels((current) => current.filter((item) => item.id !== model.id));
       toast.success(t("models.modelDeleted"));
       toast.success(t("models.modelDeleted"));
     } catch (err) {
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("models.failedToDelete"));
+      toast.error(translateApiError(err));
     }
     }
   }
   }
 
 
@@ -244,7 +245,7 @@ export function ModelsPage() {
       setTestOutput(result.response.content || JSON.stringify(result.response.raw_response_json, null, 2));
       setTestOutput(result.response.content || JSON.stringify(result.response.raw_response_json, null, 2));
       toast.success(t("models.modelTestCompleted"));
       toast.success(t("models.modelTestCompleted"));
     } catch (err) {
     } catch (err) {
-      setTestOutput(err instanceof Error ? err.message : t("models.testFailed"));
+      setTestOutput(translateApiError(err));
       toast.error(t("models.testFailed"));
       toast.error(t("models.testFailed"));
     } finally {
     } finally {
       setTesting(false);
       setTesting(false);
@@ -581,7 +582,7 @@ function ModelPicker({
           required
           required
           value={form.model_name}
           value={form.model_name}
           onChange={(event) => setModelName(event.target.value)}
           onChange={(event) => setModelName(event.target.value)}
-          placeholder="gpt-4.1-mini"
+          placeholder={t("models.modelIdPlaceholder")}
           disabled={discovering}
           disabled={discovering}
         />
         />
       </Field>
       </Field>
@@ -723,7 +724,7 @@ function ProviderCredentialPanel({
         await discoverProviderModels(provider);
         await discoverProviderModels(provider);
       }
       }
     } catch (err) {
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("errors.failedToSave"));
+      toast.error(translateApiError(err));
     } finally {
     } finally {
       setSaving(false);
       setSaving(false);
     }
     }
@@ -754,7 +755,7 @@ function ProviderCredentialPanel({
         defaultValue: `Synced ${updatedProvider.models.length} models, created ${createdCount}.`,
         defaultValue: `Synced ${updatedProvider.models.length} models, created ${createdCount}.`,
       }));
       }));
     } catch (err) {
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("modelProviders.discoverFailed"));
+      toast.error(translateApiError(err));
     } finally {
     } finally {
       setDiscovering(false);
       setDiscovering(false);
     }
     }
@@ -985,7 +986,7 @@ function CreateModelDialog({
     try {
     try {
       await onCreate(form);
       await onCreate(form);
     } catch (err) {
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("models.failedToCreate"));
+      toast.error(translateApiError(err));
     } finally {
     } finally {
       setSaving(false);
       setSaving(false);
     }
     }
@@ -1020,7 +1021,7 @@ function CreateModelDialog({
       }, { ...provider, models: result.models, default_model: provider.default_model || result.models[0]?.model_id || null }));
       }, { ...provider, models: result.models, default_model: provider.default_model || result.models[0]?.model_id || null }));
       toast.success(t("modelProviders.discovered", { count: result.models.length }));
       toast.success(t("modelProviders.discovered", { count: result.models.length }));
     } catch (err) {
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("modelProviders.discoverFailed"));
+      toast.error(translateApiError(err));
     } finally {
     } finally {
       setDiscoveringModels(false);
       setDiscoveringModels(false);
     }
     }
@@ -1172,7 +1173,7 @@ function EditModelDialog({
       }, { ...provider, models: result.models, default_model: provider.default_model || result.models[0]?.model_id || null }));
       }, { ...provider, models: result.models, default_model: provider.default_model || result.models[0]?.model_id || null }));
       toast.success(t("modelProviders.discovered", { count: result.models.length }));
       toast.success(t("modelProviders.discovered", { count: result.models.length }));
     } catch (err) {
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("modelProviders.discoverFailed"));
+      toast.error(translateApiError(err));
     } finally {
     } finally {
       setDiscoveringModels(false);
       setDiscoveringModels(false);
     }
     }

+ 2 - 1
web/src/pages/settings/SettingsPage.tsx

@@ -15,6 +15,7 @@ import {
   UserRound,
   UserRound,
 } from "lucide-react";
 } from "lucide-react";
 import { createApiKey, listApiKeys, revokeApiKey } from "@/api";
 import { createApiKey, listApiKeys, revokeApiKey } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { mockMode } from "@/api/mock";
 import { mockMode } from "@/api/mock";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { EmptyState } from "@/components/shared/EmptyState";
@@ -77,7 +78,7 @@ export function SettingsPage() {
       const keys = await listApiKeys();
       const keys = await listApiKeys();
       setApiKeys(keys);
       setApiKeys(keys);
     } catch (err) {
     } catch (err) {
-      setKeysError(err instanceof Error ? err.message : t("errors.failedToLoad"));
+      setKeysError(translateApiError(err));
     } finally {
     } finally {
       setKeysLoading(false);
       setKeysLoading(false);
     }
     }

+ 5 - 4
web/src/pages/skills/SkillsPage.tsx

@@ -2,6 +2,7 @@ import * as React from "react";
 import { useTranslation } from "react-i18next";
 import { useTranslation } from "react-i18next";
 import { CheckCircle2, Copy, FileText, Link2, Pencil, Plus, Puzzle, RefreshCw, Search, Trash2, Wrench, X } from "lucide-react";
 import { CheckCircle2, Copy, FileText, Link2, Pencil, Plus, Puzzle, RefreshCw, Search, Trash2, Wrench, X } from "lucide-react";
 import { createSkill, deleteSkill, listAllSkills, listToolConnections, listTools, updateSkill } from "@/api";
 import { createSkill, deleteSkill, listAllSkills, listToolConnections, listTools, updateSkill } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
@@ -95,7 +96,7 @@ export function SkillsPage() {
       setSkills(skillItems);
       setSkills(skillItems);
       setTools(mappedTools);
       setTools(mappedTools);
     } catch (err) {
     } catch (err) {
-      setError(err instanceof Error ? err.message : t("errors.unableToLoadData"));
+      setError(translateApiError(err));
     } finally {
     } finally {
       setLoading(false);
       setLoading(false);
     }
     }
@@ -131,7 +132,7 @@ export function SkillsPage() {
       setCreateOpen(false);
       setCreateOpen(false);
       toast.success(t("skills.created"));
       toast.success(t("skills.created"));
     } catch (err) {
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("errors.failedToCreate"));
+      toast.error(translateApiError(err));
     } finally {
     } finally {
       setSaving(false);
       setSaving(false);
     }
     }
@@ -154,7 +155,7 @@ export function SkillsPage() {
       setEditOpen(false);
       setEditOpen(false);
       toast.success(t("skills.saved"));
       toast.success(t("skills.saved"));
     } catch (err) {
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("errors.failedToSave"));
+      toast.error(translateApiError(err));
     } finally {
     } finally {
       setSaving(false);
       setSaving(false);
     }
     }
@@ -166,7 +167,7 @@ export function SkillsPage() {
       setSkills((current) => current.filter((item) => item.id !== skill.id));
       setSkills((current) => current.filter((item) => item.id !== skill.id));
       toast.success(t("skills.deleted"));
       toast.success(t("skills.deleted"));
     } catch (err) {
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("errors.failedToDelete"));
+      toast.error(translateApiError(err));
     }
     }
   }
   }
 
 

+ 3 - 2
web/src/pages/teams/TeamsPage.tsx

@@ -11,6 +11,7 @@ import {
 } from "lucide-react";
 } from "lucide-react";
 import { listAgents } from "@/api/agents";
 import { listAgents } from "@/api/agents";
 import { listTeamConfigs, listTeamRuns, listTeams } from "@/api";
 import { listTeamConfigs, listTeamRuns, listTeams } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
@@ -96,7 +97,7 @@ export function TeamsPage() {
       setAgents(agentData);
       setAgents(agentData);
       setSelectedTeamId((current) => current ?? data[0]?.id);
       setSelectedTeamId((current) => current ?? data[0]?.id);
     } catch (err) {
     } catch (err) {
-      setError(err instanceof Error ? err.message : t("errors.failedToLoad"));
+      setError(translateApiError(err));
     } finally {
     } finally {
       setLoading(false);
       setLoading(false);
     }
     }
@@ -168,7 +169,7 @@ export function TeamsPage() {
             </div>
             </div>
           </CardHeader>
           </CardHeader>
           <CardContent className="space-y-3 p-4 pt-0">
           <CardContent className="space-y-3 p-4 pt-0">
-            <SearchInput value={search} onChange={setSearch} placeholder="Search by name, type, or description" />
+            <SearchInput value={search} onChange={setSearch} placeholder={t("teams.searchPlaceholder")} />
             <div className="grid gap-3">
             <div className="grid gap-3">
               <Select
               <Select
                 aria-label={t("teams.filterByStatus")}
                 aria-label={t("teams.filterByStatus")}

+ 3 - 2
web/src/pages/teams/components/CreateTeamDialog.tsx

@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
 import { GitBranch, ListPlus, Minus, Plus, Settings2, Users } from "lucide-react";
 import { GitBranch, ListPlus, Minus, Plus, Settings2, Users } from "lucide-react";
 import { listAgents } from "@/api/agents";
 import { listAgents } from "@/api/agents";
 import { createTeam, createTeamConfig, updateTeam, updateTeamConfig } from "@/api";
 import { createTeam, createTeamConfig, updateTeam, updateTeamConfig } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { Button } from "@/components/ui/button";
 import { Button } from "@/components/ui/button";
 import { Dialog } from "@/components/ui/dialog";
 import { Dialog } from "@/components/ui/dialog";
 import { Input, Textarea } from "@/components/ui/input";
 import { Input, Textarea } from "@/components/ui/input";
@@ -89,7 +90,7 @@ export function CreateTeamDialog({
       reset();
       reset();
       onCreated(team);
       onCreated(team);
     } catch (err) {
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("teams.failedCreateTeam"));
+      toast.error(translateApiError(err));
     } finally {
     } finally {
       setSubmitting(false);
       setSubmitting(false);
     }
     }
@@ -374,7 +375,7 @@ export function EditTeamDialog({
       onOpenChange(false);
       onOpenChange(false);
       onUpdated(updatedTeam);
       onUpdated(updatedTeam);
     } catch (err) {
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("teams.failedUpdateTeam"));
+      toast.error(translateApiError(err));
     } finally {
     } finally {
       setSubmitting(false);
       setSubmitting(false);
     }
     }

+ 2 - 1
web/src/pages/teams/components/TeamRuns.tsx

@@ -2,6 +2,7 @@ import * as React from "react";
 import { useTranslation } from "react-i18next";
 import { useTranslation } from "react-i18next";
 import { Activity, AlertCircle, Check, CheckCircle2, Clock3, Copy, MessageSquareText, Play, Send } from "lucide-react";
 import { Activity, AlertCircle, Check, CheckCircle2, Clock3, Copy, MessageSquareText, Play, Send } from "lucide-react";
 import { createTeamRun, executeTeamRunStream, listTeamRuns } from "@/api";
 import { createTeamRun, executeTeamRunStream, listTeamRuns } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { StatusBadge } from "@/components/shared/StatusBadge";
 import { StatusBadge } from "@/components/shared/StatusBadge";
@@ -181,7 +182,7 @@ export function TeamRuns({
         toast.success(t("teams.teamRunStarted"));
         toast.success(t("teams.teamRunStarted"));
       }
       }
     } catch (err) {
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("teams.failedStartTeamRun"));
+      toast.error(translateApiError(err));
       await pollRunUntilSettled(run.id);
       await pollRunUntilSettled(run.id);
     } finally {
     } finally {
       setExecutingRunId(undefined);
       setExecutingRunId(undefined);

+ 2 - 1
web/src/pages/tools/ToolsPage.tsx

@@ -8,6 +8,7 @@ import {
   Wrench,
   Wrench,
 } from "lucide-react";
 } from "lucide-react";
 import { listToolBindings, listToolConnections, listTools } from "@/api";
 import { listToolBindings, listToolConnections, listTools } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
@@ -91,7 +92,7 @@ export function ToolsPage() {
       setBindings(bindingData);
       setBindings(bindingData);
       setConnections(connectionData);
       setConnections(connectionData);
     } catch (err) {
     } catch (err) {
-      setError(err instanceof Error ? err.message : t("errors.failedToLoad"));
+      setError(translateApiError(err));
     } finally {
     } finally {
       setLoading(false);
       setLoading(false);
     }
     }

+ 2 - 1
web/src/pages/tools/components/ConnectMcpServerDialog.tsx

@@ -2,6 +2,7 @@ import * as React from "react";
 import { Braces, Plus, Trash2 } from "lucide-react";
 import { Braces, Plus, Trash2 } from "lucide-react";
 import { useTranslation } from "react-i18next";
 import { useTranslation } from "react-i18next";
 import { connectMcpServer } from "@/api";
 import { connectMcpServer } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { Button } from "@/components/ui/button";
 import { Button } from "@/components/ui/button";
 import { Dialog } from "@/components/ui/dialog";
 import { Dialog } from "@/components/ui/dialog";
 import { Input } from "@/components/ui/input";
 import { Input } from "@/components/ui/input";
@@ -81,7 +82,7 @@ export function ConnectMcpServerDialog({ open, onOpenChange, onCreated }: Connec
       reset();
       reset();
       onCreated();
       onCreated();
     } catch (err) {
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("tools.failedToConnectMcp"));
+      toast.error(translateApiError(err));
     } finally {
     } finally {
       setSubmitting(false);
       setSubmitting(false);
     }
     }

+ 2 - 1
web/src/pages/tools/components/CreateToolDialog.tsx

@@ -2,6 +2,7 @@ import * as React from "react";
 import { useTranslation } from "react-i18next";
 import { useTranslation } from "react-i18next";
 import { Globe2, Plug, Search } from "lucide-react";
 import { Globe2, Plug, Search } from "lucide-react";
 import { createTool } from "@/api";
 import { createTool } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { Button } from "@/components/ui/button";
 import { Button } from "@/components/ui/button";
 import { Dialog } from "@/components/ui/dialog";
 import { Dialog } from "@/components/ui/dialog";
 import { Input, Textarea } from "@/components/ui/input";
 import { Input, Textarea } from "@/components/ui/input";
@@ -43,7 +44,7 @@ export function CreateToolDialog({ open, onOpenChange, onCreated }: CreateToolDi
       setDescription("");
       setDescription("");
       onCreated();
       onCreated();
     } catch (err) {
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : "Failed to create tool");
+      toast.error(translateApiError(err));
     } finally {
     } finally {
       setSubmitting(false);
       setSubmitting(false);
     }
     }

+ 2 - 1
web/src/pages/tools/components/ToolDetailSheet.tsx

@@ -2,6 +2,7 @@ import * as React from "react";
 import { RefreshCw, Wrench } from "lucide-react";
 import { RefreshCw, Wrench } from "lucide-react";
 import { useTranslation } from "react-i18next";
 import { useTranslation } from "react-i18next";
 import { discoverMcpConnection, listToolConnections } from "@/api";
 import { discoverMcpConnection, listToolConnections } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { Badge } from "@/components/ui/badge";
 import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
 import { Button } from "@/components/ui/button";
 import { Sheet } from "@/components/ui/sheet";
 import { Sheet } from "@/components/ui/sheet";
@@ -51,7 +52,7 @@ export function ToolDetailSheet({ tool, open, onOpenChange }: ToolDetailSheetPro
       setConnections((items) => items.map((item) => (item.id === updated.id ? updated : item)));
       setConnections((items) => items.map((item) => (item.id === updated.id ? updated : item)));
       toast.success(t("tools.discoveryCompleted", "MCP discovery completed"));
       toast.success(t("tools.discoveryCompleted", "MCP discovery completed"));
     } catch (err) {
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("tools.failedToDiscoverMcp", "Failed to discover MCP tools"));
+      toast.error(translateApiError(err));
     } finally {
     } finally {
       setDiscovering(false);
       setDiscovering(false);
     }
     }