Prechádzať zdrojové kódy

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

Jax Docker 1 mesiac pred
rodič
commit
fd804d7acb
48 zmenil súbory, kde vykonal 2067 pridanie a 1390 odobranie
  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 \"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/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 .errors import ERROR_CODES, error_detail
 from .optional_redis import try_build_redis_client
 from .types import JSONPrimitive, JSONValue
 
 __all__ = [
+    "ERROR_CODES",
     "JSONPrimitive",
     "JSONValue",
     "ServiceSettings",
+    "error_detail",
     "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
 
 from core_domain import ServiceHealth
+from core_shared import error_detail
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi.responses import StreamingResponse
 from sqlalchemy import text
@@ -87,7 +88,7 @@ def detail_agent(
     service: AgentApplicationService = Depends(get_agent_application_service)) -> AgentResponse:
     entity = service.get_agent(agent_id=payload.agent_id)
     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)
 
 
@@ -97,7 +98,7 @@ def update_agent(
     service: AgentApplicationService = Depends(get_agent_application_service)) -> AgentResponse:
     entity = service.update_agent(payload)
     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)
 
 
@@ -108,7 +109,7 @@ def update_agent_status(
     service: AgentApplicationService = Depends(get_agent_application_service)) -> AgentResponse:
     entity = service.update_agent_status(agent_id=agent_id, payload=payload)
     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)
 
 
@@ -120,7 +121,7 @@ def update_agent_status_post(
         agent_id=payload.agent_id,
         payload=AgentStatusUpdateRequest(status=payload.status))
     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)
 
 
@@ -140,7 +141,7 @@ def create_agent_config(
     try:
         entity = service.create_agent_config(payload)
     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)
 
 
@@ -161,7 +162,7 @@ def create_agent_run(
     try:
         entity = service.create_agent_run(payload)
     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)
 
 
@@ -196,7 +197,7 @@ def get_agent_run(
     service: AgentApplicationService = Depends(get_agent_application_service)) -> AgentRunResponse:
     entity = service.get_agent_run(payload)
     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)
 
 
@@ -233,7 +234,7 @@ def update_agent_run_status(
     service: AgentApplicationService = Depends(get_agent_application_service)) -> AgentRunResponse:
     entity = service.update_agent_run_status(agent_run_id=agent_run_id, payload=payload)
     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)
 
 
@@ -251,7 +252,7 @@ def update_agent_run_status_post(
             error_code=payload.error_code,
             error_message=payload.error_message))
     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)
 
 
@@ -262,7 +263,7 @@ def execute_agent_run(
     service: AgentApplicationService = Depends(get_agent_application_service)) -> AgentRunExecuteResponse:
     entity = service.execute_agent_run(agent_run_id=agent_run_id, payload=payload)
     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 {}
     model_value = output_json.get("model")
@@ -279,7 +280,7 @@ def execute_agent_run_stream(
     payload: AgentRunExecuteRequest,
     service: AgentApplicationService = Depends(get_agent_application_service)) -> StreamingResponse:
     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():
         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,
             dry_run=payload.dry_run))
     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 {}
     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,
         dry_run=payload.dry_run if payload.dry_run is not None else settings.worker_dry_run)
     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
     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.proxy import ProxyServiceName, ProxyTarget, ServiceProxy
+from core_shared import error_detail
 from core_shared.security import build_internal_service_headers
 from app.schemas.gateway import (
     ApiKeyCreateRequest,
@@ -139,7 +140,7 @@ def update_api_key_status(
         api_key_id=api_key_id,
         status=payload.status)
     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)
 
 
@@ -151,7 +152,7 @@ def update_api_key_status_post(
         api_key_id=payload.api_key_id,
         status=payload.status)
     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)
 
 
@@ -319,7 +320,7 @@ async def execute_session(
         target_id = _get_string(session, "runtime_target_id")
         target_config_id = _get_optional_string(session, "runtime_target_config_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 = {
             "target_type": target_type,
@@ -514,7 +515,7 @@ async def _stream_session_execute(
         target_id = _get_string(session, "runtime_target_id")
         target_config_id = _get_optional_string(session, "runtime_target_config_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 = {
             "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:
     existing = AppDefinitionRepository(db).get_by_code(code=payload.code)
     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(
         code=payload.code,
         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:
     entity = AppDefinitionRepository(db).get_by_id(app_id=payload.app_id)
     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)
 
 
@@ -698,7 +699,7 @@ def update_app(payload: AppUpdateRequest, db: DbSession) -> AppResponse:
         target_id=payload.target_id,
         settings_json=payload.settings_json)
     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)
 
 
@@ -706,7 +707,7 @@ def update_app(payload: AppUpdateRequest, 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)
     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)
 
 
@@ -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:
     app_entity = AppDefinitionRepository(db).get_by_id(app_id=app_id)
     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()
     entity = AppApiKeyRepository(db).create(
         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:
     entity = AppApiKeyRepository(db).update_status(api_key_id=payload.api_key_id, status=payload.status)
     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)
 
 
@@ -770,20 +771,20 @@ def _authenticate_app_api_key(request: Request, db: Session):
     if token is None:
         token = request.headers.get(settings.api_key_header_name)
     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_entity = AppApiKeyRepository(db).get_active_by_hash(key_hash=key_hash)
     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():
-        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)
     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":
-        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)
     return key_entity, app_entity
@@ -795,7 +796,7 @@ async def openapi_chat(app_code: str, payload: OpenApiChatRequest, request: Requ
     request_id = str(uuid4())
     key_entity, app_entity = _authenticate_app_api_key(request, db)
     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())
     session_target = targets["session-service"]
@@ -1410,12 +1411,12 @@ async def _post_json(
     try:
         response = await client.post(url, headers=headers, json=payload)
     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:
         raise HTTPException(status_code=response.status_code, detail=_error_detail(response))
     data = response.json()
     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
 
 
@@ -1426,11 +1427,11 @@ def _target_url(target: ProxyTarget, path: str) -> str:
     return f"{target.base_url.rstrip('/')}{target.path_prefix}"
 
 
-def _error_detail(response: httpx.Response) -> str:
+def _error_detail(response: httpx.Response):
     try:
         payload = response.json()
     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):
         detail = payload.get("detail")
         if isinstance(detail, str):
@@ -1440,13 +1441,13 @@ def _error_detail(response: httpx.Response) -> str:
             message = error.get("message")
             if isinstance(message, str):
                 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:
     value = payload.get(key)
     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
 
 
@@ -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]:
     value = payload.get(key)
     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
 
 

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

@@ -2,7 +2,7 @@ from datetime import datetime
 from typing import Annotated, TypeVar
 
 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 sqlalchemy import text
 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:
     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(" ")
     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
 
 
@@ -91,7 +91,7 @@ def login(
     service: IdentityServiceDep) -> ApiResponse[LoginData]:
     result = service.login(username=payload.username, password=payload.password)
     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(
         request,
         LoginData(
@@ -133,10 +133,10 @@ def me(
     token = get_bearer_token(authorization)
     verified = service.verify_token(access_token=token)
     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)
     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)
     roles = []
@@ -299,5 +299,5 @@ def revoke_api_key(
     service: IdentityServiceDep) -> ApiResponse[ApiKeyDto]:
     entity = service.revoke_api_key(api_key_id=payload.apiKeyId)
     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))

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

@@ -1,4 +1,5 @@
 from core_domain import CodeExecutionRequestContract, CodeExecutionResponseContract, ServiceHealth
+from core_shared import error_detail
 from fastapi import APIRouter, Depends, HTTPException
 
 from app.application.services import CodeRunnerApplicationService
@@ -32,4 +33,4 @@ def execute_code(
     try:
         return service.execute_code(payload)
     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_events import EventDeliveryStatus
+from core_shared import error_detail
 from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import text
 from sqlalchemy.orm import Session
@@ -108,7 +109,7 @@ def update_delivery_status(
         event_record_id=event_record_id,
         payload=payload)
     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)
 
 
@@ -122,7 +123,7 @@ def update_delivery_status_post(
             status=payload.status,
             last_error_message=payload.last_error_message))
     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)
 
 

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

@@ -1,4 +1,5 @@
 from core_domain import HumanTaskStatus, ServiceHealth
+from core_shared import error_detail
 from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import text
 from sqlalchemy.orm import Session
@@ -74,7 +75,7 @@ def get_human_task(
     service: HumanApplicationService = Depends(get_human_application_service)) -> HumanTaskResponse:
     entity = service.get_task(human_task_id=human_task_id)
     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)
 
 
@@ -84,7 +85,7 @@ def get_human_task_post(
     service: HumanApplicationService = Depends(get_human_application_service)) -> HumanTaskResponse:
     entity = service.get_task(human_task_id=payload.human_task_id)
     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)
 
 
@@ -95,7 +96,7 @@ def claim_human_task(
     service: HumanApplicationService = Depends(get_human_application_service)) -> HumanTaskResponse:
     entity = service.claim_task(human_task_id=human_task_id, payload=payload)
     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)
 
 
@@ -107,7 +108,7 @@ def claim_human_task_post(
         human_task_id=payload.human_task_id,
         payload=HumanTaskClaimRequest(claimed_by=payload.claimed_by))
     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)
 
 
@@ -118,7 +119,7 @@ def complete_human_task(
     service: HumanApplicationService = Depends(get_human_application_service)) -> HumanTaskResponse:
     entity = service.complete_task(human_task_id=human_task_id, payload=payload)
     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)
 
 
@@ -132,5 +133,5 @@ def complete_human_task_post(
             status=payload.status,
             response_payload_json=payload.response_payload_json))
     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)

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

@@ -2,6 +2,7 @@ from datetime import datetime
 from typing import TypeVar
 
 from core_domain import ServiceHealth
+from core_shared import error_detail
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import text
 from sqlalchemy.orm import Session
@@ -145,7 +146,7 @@ def detail_base_contract(
     service: KnowledgeApplicationService = Depends(get_knowledge_application_service)) -> ApiResponse[KnowledgeBaseDto]:
     entity = service.base_repository.get_by_id(knowledge_base_id=payload.knowledgeBaseId)
     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))
 
 
@@ -155,7 +156,7 @@ def update_base_contract(
     service: KnowledgeApplicationService = Depends(get_knowledge_application_service)) -> ApiResponse[KnowledgeBaseDto]:
     entity = service.update_base_from_contract(payload)
     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))
 
 
@@ -167,7 +168,7 @@ def update_base_status_contract(
         knowledge_base_id=payload.knowledgeBaseId,
         payload=KnowledgeBaseStatusUpdateRequest(status=payload.status))
     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))
 
 
@@ -178,7 +179,7 @@ def delete_base_contract(
     try:
         deleted = service.delete_base(knowledge_base_id=payload.knowledgeBaseId)
     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(
         deleted=deleted,
         knowledgeBaseId=payload.knowledgeBaseId))
@@ -206,11 +207,11 @@ def create_document_contract(
     try:
         result = service.create_document_from_contract_result(payload)
     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:
-        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:
-        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(
         document=KnowledgeDocumentDto.from_entity(result.document),
         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]:
     entity = service.document_repository.get_by_id(document_id=payload.documentId)
     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))
 
 
@@ -234,7 +235,7 @@ def update_document_contract(
     service: KnowledgeApplicationService = Depends(get_knowledge_application_service)) -> ApiResponse[KnowledgeDocumentDto]:
     entity = service.update_document_from_contract(payload)
     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))
 
 
@@ -246,7 +247,7 @@ def update_document_status_contract(
         document_id=payload.documentId,
         status=payload.status)
     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))
 
 
@@ -257,7 +258,7 @@ def delete_document_contract(
     try:
         result = service.delete_document_result(document_id=payload.documentId)
     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(
         deleted=bool(result.get("deleted")),
         documentId=payload.documentId,
@@ -271,13 +272,13 @@ def reindex_document_contract(
     try:
         result = service.reindex_document_from_contract_result(payload)
     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:
-        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:
-        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:
-        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(
         document=KnowledgeDocumentDto.from_entity(result.document),
         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]:
     job = service.detail_index_job(document_id=payload.documentId)
     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)
 
 
@@ -317,7 +318,7 @@ def retry_index_job_contract(
             chunkOverlap=payload.chunkOverlap,
             asyncMode=True))
     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(
         document=KnowledgeDocumentDto.from_entity(result.document),
         chunks=[KnowledgeChunkDto.from_entity(item) for item in result.chunks],
@@ -330,7 +331,7 @@ def reindex_base_contract(
     payload: KnowledgeBaseReindexRequestDto,
     service: KnowledgeApplicationService = Depends(get_knowledge_application_service)) -> ApiResponse[KnowledgeBaseReindexData]:
     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)
     return ok(KnowledgeBaseReindexData(
         knowledgeBaseId=payload.knowledgeBaseId,
@@ -348,11 +349,11 @@ def read_document_content_contract(
             include_text=payload.includeText,
             include_base64=payload.includeBase64)
     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:
-        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:
-        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))
 
 
@@ -363,9 +364,9 @@ def read_document_storage_status_contract(
     try:
         result = service.read_document_storage_status(document_id=payload.documentId)
     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:
-        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({
         **result,
         "checkedTime": datetime.utcnow(),
@@ -384,7 +385,7 @@ def parse_document_contract(
                 content_text=payload.contentText,
                 content_base64=payload.contentBase64))
     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(
         contentText=parsed.content_text,
         sourceType=parsed.source_type,
@@ -411,7 +412,7 @@ def detail_chunk_contract(
     service: KnowledgeApplicationService = Depends(get_knowledge_application_service)) -> ApiResponse[KnowledgeChunkDto]:
     entity = service.chunk_repository.get_by_id(chunk_id=payload.chunkId)
     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))
 
 

+ 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]]:
     """Dispatch to the appropriate chunker based on source_type."""
     normalized = source_type.strip().lower()
-    text_for_chunking = raw_content or content_text
     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":
         chunks = _chunk_json(content_text, chunk_size=chunk_size)
     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.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.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.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 (
     KnowledgeBaseCreateRequest,
     KnowledgeBaseCreateRequestDto,
+    KnowledgeBaseReindexRequestDto,
     KnowledgeBaseStatusUpdateRequest,
     KnowledgeBaseUpdateRequestDto,
-    KnowledgeBaseReindexRequestDto,
     KnowledgeDocumentCreateRequest,
     KnowledgeDocumentCreateRequestDto,
-    KnowledgeIndexJobAction,
-    KnowledgeIndexJobData,
-    KnowledgeIndexJobStatus,
     KnowledgeDocumentParseRequest,
     KnowledgeDocumentReindexRequestDto,
     KnowledgeDocumentUpdateRequestDto,
+    KnowledgeIndexJobData,
+    KnowledgeSearchRequest,
     KnowledgeSettingsDto,
     KnowledgeSettingsUpdateRequestDto,
-    KnowledgeSearchRequest)
+)
 
 if TYPE_CHECKING:
     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:
+    """Facade composing CRUD, Indexing, Search, and Settings sub-services."""
+
     def __init__(
         self,
         *,
@@ -87,7 +58,8 @@ class KnowledgeApplicationService:
         chunk_repository: KnowledgeChunkRepository,
         object_storage: KnowledgeObjectStorage | 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.base_repository = base_repository
         self.document_repository = document_repository
@@ -97,1134 +69,179 @@ class KnowledgeApplicationService:
         self.redis_client = redis_client
         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
     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:
-        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]:
-        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:
-        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:
-        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]:
-        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:
-        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]:
-        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:
-        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(
     *,
-    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)
     return KnowledgeApplicationService(
         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 core_domain import ServiceHealth
+from core_shared import error_detail
 from fastapi import APIRouter, Depends, HTTPException, Request
 from sqlalchemy import text
 from sqlalchemy.orm import Session
@@ -76,7 +77,7 @@ def detail_memory_contract(
     service: MemoryApplicationService = Depends(get_memory_application_service)) -> ApiResponse[MemoryItemDto]:
     entity = service.get_memory(memory_id=payload.memoryId)
     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))
 
 
@@ -86,7 +87,7 @@ def update_memory_contract(
     service: MemoryApplicationService = Depends(get_memory_application_service)) -> ApiResponse[MemoryItemDto]:
     entity = service.update_memory(payload)
     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))
 
 
@@ -98,7 +99,7 @@ def update_memory_status_contract(
         memory_id=payload.memoryId,
         payload=payload)
     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))
 
 
@@ -108,7 +109,7 @@ def delete_memory_contract(
     service: MemoryApplicationService = Depends(get_memory_application_service)) -> ApiResponse[DeleteData]:
     entity = service.delete_memory(memory_id=payload.memoryId)
     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))
 
 

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

@@ -9,6 +9,7 @@ from core_domain import (
     EmbeddingResponseContract,
     ServiceHealth,
 )
+from core_shared import error_detail
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi.responses import StreamingResponse
 from sqlalchemy import text
@@ -105,7 +106,7 @@ def create_model(
     try:
         entity = service.create_model(payload)
     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)
 
 
@@ -125,9 +126,9 @@ def update_model(
     try:
         entity = service.update_model(model_id=model_id, payload=payload)
     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:
-        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)
 
 
@@ -139,7 +140,7 @@ def update_model_status(
 ) -> ModelResponse:
     entity = service.update_model_status(model_id=model_id, payload=payload)
     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)
 
 
@@ -149,7 +150,7 @@ def delete_model(
     service: ModelServiceDep,
 ) -> None:
     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)
@@ -161,9 +162,9 @@ def test_model(
     try:
         result = service.test_model(model_id=model_id, payload=payload)
     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:
-        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
 
 
@@ -174,7 +175,7 @@ def create_chat_completion(
     try:
         return service.create_chat_completion(payload)
     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")
@@ -205,7 +206,7 @@ def create_embedding(
     try:
         return service.create_embedding(payload)
     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]])
@@ -237,7 +238,7 @@ def create_model_contract(
     try:
         entity = service.create_model_from_contract(payload)
     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))
 
 
@@ -248,9 +249,9 @@ def update_model_contract(
     try:
         entity = service.update_model_from_contract(payload)
     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:
-        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))
 
 
@@ -269,9 +270,9 @@ def test_model_contract(
     try:
         result = service.test_model_from_contract(payload)
     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:
-        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)
 
 
@@ -311,7 +312,7 @@ def update_model_provider_contract(
     service: ModelServiceDep) -> ApiResponse[ModelProviderDto]:
     entity = service.update_provider(payload)
     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))
 
 
@@ -330,9 +331,9 @@ def test_model_provider_contract(
     try:
         result = service.test_provider(payload)
     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:
-        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)
 
 
@@ -343,4 +344,4 @@ def discover_models_contract(
     try:
         return ok(service.discover_models(payload))
     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_shared import try_build_redis_client
+from core_shared import error_detail, try_build_redis_client
 from core_shared.task_queue import TaskQueuePublisher
 from fastapi import APIRouter, Depends, HTTPException, Query, Request
 from sqlalchemy import text
@@ -91,7 +91,7 @@ def update_job_status(
     service: SchedulerApplicationService = Depends(get_scheduler_application_service)) -> ScheduledJobResponse:
     entity = service.update_job_status(job_id=job_id, payload=payload)
     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)
 
 
@@ -105,5 +105,5 @@ def update_job_status_post(
             status=payload.status,
             last_error_message=payload.last_error_message))
     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)

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

@@ -1,4 +1,5 @@
 from core_domain import ServiceHealth
+from core_shared import error_detail
 from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import text
 from sqlalchemy.orm import Session
@@ -67,7 +68,7 @@ def detail_session(
     service: SessionApplicationService = Depends(get_session_application_service)) -> SessionResponse:
     entity = service.get_session(session_id=payload.session_id)
     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)
 
 
@@ -133,7 +134,7 @@ def get_run_request(
     service: SessionApplicationService = Depends(get_session_application_service)) -> RunRequestResponse:
     entity = service.run_request_repository.get_by_id(run_request_id=payload.run_request_id)
     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)
 
 
@@ -143,5 +144,5 @@ def update_run_request(
     service: SessionApplicationService = Depends(get_session_application_service)) -> RunRequestResponse:
     entity = service.update_run_request(payload)
     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)

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

@@ -3,6 +3,7 @@ from typing import TypeVar
 from uuid import uuid4
 
 from core_domain import ServiceHealth
+from core_shared import error_detail
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import text
 from sqlalchemy.orm import Session
@@ -85,7 +86,7 @@ def detail_skill_contract(
     service: SkillApplicationService = Depends(get_skill_application_service)) -> ApiResponse[SkillDefinitionDto]:
     entity = service.skill_repository.get_by_id(skill_id=payload.skillId)
     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))
 
 
@@ -95,7 +96,7 @@ def update_skill_contract(
     service: SkillApplicationService = Depends(get_skill_application_service)) -> ApiResponse[SkillDefinitionDto]:
     entity = service.update_skill(payload)
     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))
 
 
@@ -105,7 +106,7 @@ def update_skill_status_contract(
     service: SkillApplicationService = Depends(get_skill_application_service)) -> ApiResponse[SkillDefinitionDto]:
     entity = service.update_skill_status_contract(payload)
     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))
 
 
@@ -115,7 +116,7 @@ def delete_skill_contract(
     service: SkillApplicationService = Depends(get_skill_application_service)) -> ApiResponse[DeleteData]:
     entity = service.delete_skill(payload)
     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))
 
 
@@ -138,7 +139,7 @@ def install_skill_contract(
     try:
         return ok(SkillInstallationDto.from_entity(service.install_skill_from_contract(payload)))
     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])
@@ -147,7 +148,7 @@ def update_installation_status_contract(
     service: SkillApplicationService = Depends(get_skill_application_service)) -> ApiResponse[SkillInstallationDto]:
     entity = service.update_installation_status_contract(payload)
     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))
 
 
@@ -158,7 +159,7 @@ def create_skill_run(
     try:
         return ok(SkillRunDto.from_entity(service.create_skill_run_from_contract(payload)))
     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])
@@ -167,5 +168,5 @@ def execute_skill_run(
     service: SkillApplicationService = Depends(get_skill_application_service)) -> ApiResponse[SkillRunDto]:
     entity = service.execute_skill_run_from_contract(payload)
     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))

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

@@ -3,7 +3,7 @@ from datetime import datetime
 from typing import TypeVar
 
 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.responses import StreamingResponse
 from sqlalchemy import text
@@ -139,7 +139,7 @@ def get_team_contract(
     service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamDto]:
     entity = service.get_team(team_id=payload.teamId)
     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))
 
 
@@ -149,7 +149,7 @@ def update_team_contract(
     service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamDto]:
     entity = service.update_team_from_contract(payload)
     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))
 
 
@@ -168,7 +168,7 @@ def update_team_status(
     service: TeamApplicationService = Depends(get_team_application_service)) -> TeamResponse:
     entity = service.update_team_status(team_id=team_id, payload=payload)
     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)
 
 
@@ -192,7 +192,7 @@ def create_team_config_contract(
     try:
         entity = service.create_team_config_from_contract(payload)
     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))
 
 
@@ -202,7 +202,7 @@ def get_team_config_contract(
     service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamConfigDto]:
     entity = service.get_team_config(config_id=payload.configId)
     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))
 
 
@@ -213,9 +213,9 @@ def update_team_config_contract(
     try:
         entity = service.update_team_config_from_contract(payload)
     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:
-        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))
 
 
@@ -234,7 +234,7 @@ def create_team_run(
     try:
         entity = service.create_team_run(payload)
     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)
 
 
@@ -275,7 +275,7 @@ def create_team_run_contract(
     try:
         entity = service.create_team_run_from_contract(payload)
     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))
 
 
@@ -285,7 +285,7 @@ def get_team_run_contract(
     service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamRunDto]:
     entity = service.get_team_run(team_run_id=payload.teamRunId)
     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))
 
 
@@ -295,7 +295,7 @@ def update_team_run_status_contract(
     service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamRunDto]:
     entity = service.update_team_run_status_from_contract(payload)
     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))
 
 
@@ -309,7 +309,7 @@ def execute_team_run_contract(
             worker_key=payload.workerKey,
             dry_run=payload.dryRun))
     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 {}
     member_run_count = output_json.get("member_run_count")
     dry_run = output_json.get("dry_run")
@@ -324,7 +324,7 @@ def execute_team_run_stream_contract(
     payload: TeamRunExecuteRequestDto,
     service: TeamApplicationService = Depends(get_team_application_service)) -> StreamingResponse:
     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():
         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:
     entity = service.update_team_run_status(team_run_id=team_run_id, payload=payload)
     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)
 
 
@@ -369,7 +369,7 @@ def execute_team_run(
     service: TeamApplicationService = Depends(get_team_application_service)) -> TeamRunExecuteResponse:
     entity = service.execute_team_run(team_run_id=team_run_id, payload=payload)
     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 {}
     member_run_count = output_json.get("member_run_count")
     dry_run = output_json.get("dry_run")
@@ -385,7 +385,7 @@ def execute_team_run_stream(
     payload: TeamRunExecuteRequest,
     service: TeamApplicationService = Depends(get_team_application_service)) -> StreamingResponse:
     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():
         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,
         redis_client=try_build_redis_client(settings.redis_url))
     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
     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 core_domain import ServiceHealth
+from core_shared import error_detail
 from fastapi import APIRouter, Depends, HTTPException, Query, Request
 from sqlalchemy import text
 from sqlalchemy.orm import Session
@@ -102,7 +103,7 @@ def create_tool_binding(
     try:
         entity = service.create_tool_binding(payload)
     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)
 
 
@@ -122,7 +123,7 @@ def get_tool_binding_detail(
     service: ToolServiceDep) -> ToolBindingDetailResponse:
     result = service.get_tool_binding_detail(binding_id=binding_id)
     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
     return ToolBindingDetailResponse(
@@ -155,7 +156,7 @@ def reveal_tool_credential(
     result = service.reveal_tool_credential(
         credential_id=credential_id)
     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
     return ToolCredentialRevealResponse(
         credential=ToolCredentialResponse.from_entity(credential),
@@ -198,7 +199,7 @@ def get_tool_contract(
     service: ToolServiceDep) -> ApiResponse[ToolDto]:
     entity = service.get_tool_definition_from_contract(payload)
     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))
 
 
@@ -208,7 +209,7 @@ def update_tool_contract(
     service: ToolServiceDep) -> ApiResponse[ToolDto]:
     entity = service.update_tool_definition_from_contract(payload)
     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))
 
 
@@ -250,7 +251,7 @@ def get_tool_connection_contract(
     if entity is None:
         raise HTTPException(
             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))
 
 
@@ -262,7 +263,7 @@ def update_tool_connection_contract(
     if entity is None:
         raise HTTPException(
             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))
 
 
@@ -290,7 +291,7 @@ def discover_mcp_server_contract(
     if connection is None:
         raise HTTPException(
             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))
 
 
@@ -315,7 +316,7 @@ def create_tool_binding_contract(
     try:
         entity = service.create_tool_binding_from_contract(payload)
     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))
 
 
@@ -327,7 +328,7 @@ def get_tool_binding_contract(
     if entity is None:
         raise HTTPException(
             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))
 
 
@@ -339,7 +340,7 @@ def update_tool_binding_contract(
     if entity is None:
         raise HTTPException(
             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))
 
 
@@ -388,7 +389,7 @@ def get_tool_credential_contract(
     if entity is None:
         raise HTTPException(
             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))
 
 
@@ -400,7 +401,7 @@ def update_tool_credential_contract(
     if entity is None:
         raise HTTPException(
             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))
 
 
@@ -421,5 +422,5 @@ def reveal_tool_credential_contract(
     if result is None:
         raise HTTPException(
             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)

+ 56 - 46
tests/test_team_service.py

@@ -1,5 +1,6 @@
 from pathlib import Path
 from datetime import datetime
+from unittest.mock import MagicMock
 
 from tests.conftest import (
     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:
     prepare_known_service_import("team-service")
-    from unittest.mock import MagicMock
     from app.application.services import TeamApplicationService, TeamMemberRunResult
     from core_domain import AgentRunContract, TeamMemberContract
 
@@ -208,7 +208,7 @@ def _build_service_with_mock_agent() -> tuple:
             run=AgentRunContract(
                 id=f"run_{member.member_key}",
                 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_json={},
                 status="completed",
@@ -236,11 +236,36 @@ def _build_service_with_mock_agent() -> tuple:
         team_config_repository=None,
         team_run_repository=None,
         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:
-    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 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"),
     ]
 
-    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):
         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)
 
     # 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:
-    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 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"),
     ]
 
-    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):
         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)
 
     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:
-    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 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"),
     ]
 
-    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):
         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)
 
     # 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:
-    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
 
     members = [
@@ -343,11 +362,9 @@ def test_failure_mode_continue_allows_partial_failure() -> None:
         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
 
@@ -355,26 +372,19 @@ def test_failure_mode_continue_allows_partial_failure() -> None:
         nonlocal call_count
         call_count += 1
         if member.member_key == "m1":
+            from app.application.services import TeamMemberRunResult
             return TeamMemberRunResult(
                 member=member,
                 run=AgentRunContract(
-                    id="run_fail", agent_id="a1",
+                    id="run_fail", agent_id="a1", agent_config_id="c1",
                     status="failed", error_code="test_error",
                     error_message="boom",
                     created_time=datetime.utcnow()))
         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):
         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)
 
     # 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:
-    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_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_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

+ 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 (
     <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" />

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

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

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

@@ -70,7 +70,11 @@
     "notSet": "Not set",
     "configured": "Configured",
     "collapse": "Collapse",
-    "expand": "Expand"
+    "expand": "Expand",
+    "format": {
+      "json": "JSON",
+      "markdown": "Markdown"
+    }
   },
   "status": {
     "unknown": "Unknown",
@@ -916,7 +920,7 @@
     "title": "Teams",
     "description": "Manage multi-agent teams: configure members, policies, and collaborative runs.",
     "newTeam": "New Team",
-    "searchPlaceholder": "Search teams...",
+    "searchPlaceholder": "Search by name, type, or description",
     "searchByNameType": "Search by name, id, type, or description",
     "teamsShown": "of {{count}} teams shown",
     "allStatuses": "All statuses",
@@ -1223,7 +1227,57 @@
     "failedToSave": "Failed to save",
     "unableToLoadData": "Unable to load data",
     "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": {
     "title": "Apps",
@@ -1268,6 +1322,7 @@
     "createKey": "Create Key",
     "keyNamePlaceholder": "Production key",
     "scopes": "Scopes",
+    "apiKeyScopesPlaceholder": "app:invoke app:stream",
     "keyDisabled": "API key disabled",
     "disableKey": "Disable",
     "noKeys": "No API keys",
@@ -1333,6 +1388,7 @@
     "showAdvanced": "Show advanced options",
     "hideAdvanced": "Hide advanced options",
     "localChatPlaceholder": "Local Chat",
+    "modelIdPlaceholder": "gpt-4.1-mini",
     "modelTestCompleted": "Model test completed",
     "providerOpenaiCompatible": "OpenAI compatible",
     "providerOpenai": "OpenAI",

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

@@ -70,7 +70,11 @@
     "notSet": "未设置",
     "configured": "已配置",
     "collapse": "收起",
-    "expand": "展开"
+    "expand": "展开",
+    "format": {
+      "json": "JSON",
+      "markdown": "Markdown"
+    }
   },
   "status": {
     "unknown": "未知",
@@ -916,7 +920,7 @@
     "title": "团队",
     "description": "管理多智能体团队:配置成员、策略和协作运行。",
     "newTeam": "新建团队",
-    "searchPlaceholder": "搜索团队...",
+    "searchPlaceholder": "按名称、类型或描述搜索",
     "searchByNameType": "按名称、ID、类型或描述搜索",
     "teamsShown": "已显示 {{count}} 个团队",
     "allStatuses": "全部状态",
@@ -1223,7 +1227,57 @@
     "failedToSave": "保存失败",
     "unableToLoadData": "无法加载数据",
     "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": {
     "title": "应用",
@@ -1268,6 +1322,7 @@
     "createKey": "创建 Key",
     "keyNamePlaceholder": "生产环境 Key",
     "scopes": "权限范围",
+    "apiKeyScopesPlaceholder": "app:invoke app:stream",
     "keyDisabled": "API Key 已禁用",
     "disableKey": "禁用",
     "noKeys": "暂无 API Key",
@@ -1333,6 +1388,7 @@
     "showAdvanced": "显示高级选项",
     "hideAdvanced": "隐藏高级选项",
     "localChatPlaceholder": "本地对话",
+    "modelIdPlaceholder": "gpt-4.1-mini",
     "modelTestCompleted": "模型测试已完成",
     "providerOpenaiCompatible": "OpenAI 兼容",
     "providerOpenai": "OpenAI",

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

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

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

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

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

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

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

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

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

@@ -156,7 +156,7 @@ function CreateAppApiKeyDialog({
           </div>
           <div>
             <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 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 { listTeams } from "@/api";
 import { createApp, createAppApiKey } from "@/api/apps";
+import { translateApiError } from "@/api/errors";
 import { Button } from "@/components/ui/button";
 import { Dialog } from "@/components/ui/dialog";
 import { Input } from "@/components/ui/input";
@@ -80,7 +81,7 @@ export function CreateAppDialog({
       setStep("key");
       onCreated(app);
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("errors.failedToCreate"));
+      toast.error(translateApiError(err));
     } finally {
       setSaving(false);
     }

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

@@ -40,6 +40,7 @@ import {
   updateKnowledgeSettings,
   updateKnowledgeBaseStatus,
 } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { EmptyState } from "@/components/shared/EmptyState";
 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 { 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 = {
   id: string;
@@ -206,6 +192,16 @@ const capabilityGroups = [
 export function KnowledgePage() {
   const { t } = useTranslation();
   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 = knowledgeSections.some((item) => item.value === sectionParam) ? sectionParam ?? "overview" : "overview";
   const [bases, setBases] = React.useState<KnowledgeBase[]>([]);
@@ -270,7 +266,7 @@ export function KnowledgePage() {
       setBases(data);
       setSelectedBaseId((current) => current ?? data[0]?.id);
     } catch (err) {
-      setError(err instanceof Error ? err.message : t("knowledge.failedToLoadBases"));
+      setError(translateApiError(err));
     } finally {
       setLoading(false);
     }
@@ -381,7 +377,7 @@ export function KnowledgePage() {
       if (section !== "playground") navigate("/knowledge/playground");
       toast.info(data.length ? t("knowledge.searchResults", { count: data.length }) : t("knowledge.noMatchingChunks"));
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("knowledge.searchFailed"));
+      toast.error(translateApiError(err));
     } finally {
       setSearching(false);
     }
@@ -578,7 +574,7 @@ export function KnowledgePage() {
             <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)}>
                 <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}` }))} />
                 <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>
@@ -858,7 +854,7 @@ function DocumentsPanel({
           </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]">
             <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>
           </div>
         </div>
@@ -1536,7 +1532,7 @@ function CreateKnowledgeDocumentDialog({
       setParsePreview(parsed);
       toast.success(t("knowledge.parsePreviewReady"));
     } catch (err) {
-      setError(err instanceof Error ? err.message : t("knowledge.parseFailed"));
+      setError(translateApiError(err));
     } finally {
       setParsing(false);
     }
@@ -1564,7 +1560,7 @@ function CreateKnowledgeDocumentDialog({
       setParsePreview(undefined);
       onCreated(ingest);
     } catch (err) {
-      setError(err instanceof Error ? err.message : t("knowledge.documentIngestFailed"));
+      setError(translateApiError(err));
     } finally {
       setSubmitting(false);
     }
@@ -1583,7 +1579,7 @@ function CreateKnowledgeDocumentDialog({
         <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.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>
         </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>

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

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

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

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

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

@@ -15,6 +15,7 @@ import {
   UserRound,
 } from "lucide-react";
 import { createApiKey, listApiKeys, revokeApiKey } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { mockMode } from "@/api/mock";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { EmptyState } from "@/components/shared/EmptyState";
@@ -77,7 +78,7 @@ export function SettingsPage() {
       const keys = await listApiKeys();
       setApiKeys(keys);
     } catch (err) {
-      setKeysError(err instanceof Error ? err.message : t("errors.failedToLoad"));
+      setKeysError(translateApiError(err));
     } finally {
       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 { 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 { translateApiError } from "@/api/errors";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
@@ -95,7 +96,7 @@ export function SkillsPage() {
       setSkills(skillItems);
       setTools(mappedTools);
     } catch (err) {
-      setError(err instanceof Error ? err.message : t("errors.unableToLoadData"));
+      setError(translateApiError(err));
     } finally {
       setLoading(false);
     }
@@ -131,7 +132,7 @@ export function SkillsPage() {
       setCreateOpen(false);
       toast.success(t("skills.created"));
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("errors.failedToCreate"));
+      toast.error(translateApiError(err));
     } finally {
       setSaving(false);
     }
@@ -154,7 +155,7 @@ export function SkillsPage() {
       setEditOpen(false);
       toast.success(t("skills.saved"));
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("errors.failedToSave"));
+      toast.error(translateApiError(err));
     } finally {
       setSaving(false);
     }
@@ -166,7 +167,7 @@ export function SkillsPage() {
       setSkills((current) => current.filter((item) => item.id !== skill.id));
       toast.success(t("skills.deleted"));
     } 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";
 import { listAgents } from "@/api/agents";
 import { listTeamConfigs, listTeamRuns, listTeams } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
@@ -96,7 +97,7 @@ export function TeamsPage() {
       setAgents(agentData);
       setSelectedTeamId((current) => current ?? data[0]?.id);
     } catch (err) {
-      setError(err instanceof Error ? err.message : t("errors.failedToLoad"));
+      setError(translateApiError(err));
     } finally {
       setLoading(false);
     }
@@ -168,7 +169,7 @@ export function TeamsPage() {
             </div>
           </CardHeader>
           <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">
               <Select
                 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 { listAgents } from "@/api/agents";
 import { createTeam, createTeamConfig, updateTeam, updateTeamConfig } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { Button } from "@/components/ui/button";
 import { Dialog } from "@/components/ui/dialog";
 import { Input, Textarea } from "@/components/ui/input";
@@ -89,7 +90,7 @@ export function CreateTeamDialog({
       reset();
       onCreated(team);
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("teams.failedCreateTeam"));
+      toast.error(translateApiError(err));
     } finally {
       setSubmitting(false);
     }
@@ -374,7 +375,7 @@ export function EditTeamDialog({
       onOpenChange(false);
       onUpdated(updatedTeam);
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("teams.failedUpdateTeam"));
+      toast.error(translateApiError(err));
     } finally {
       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 { Activity, AlertCircle, Check, CheckCircle2, Clock3, Copy, MessageSquareText, Play, Send } from "lucide-react";
 import { createTeamRun, executeTeamRunStream, listTeamRuns } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { StatusBadge } from "@/components/shared/StatusBadge";
@@ -181,7 +182,7 @@ export function TeamRuns({
         toast.success(t("teams.teamRunStarted"));
       }
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("teams.failedStartTeamRun"));
+      toast.error(translateApiError(err));
       await pollRunUntilSettled(run.id);
     } finally {
       setExecutingRunId(undefined);

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

@@ -8,6 +8,7 @@ import {
   Wrench,
 } from "lucide-react";
 import { listToolBindings, listToolConnections, listTools } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
@@ -91,7 +92,7 @@ export function ToolsPage() {
       setBindings(bindingData);
       setConnections(connectionData);
     } catch (err) {
-      setError(err instanceof Error ? err.message : t("errors.failedToLoad"));
+      setError(translateApiError(err));
     } finally {
       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 { useTranslation } from "react-i18next";
 import { connectMcpServer } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { Button } from "@/components/ui/button";
 import { Dialog } from "@/components/ui/dialog";
 import { Input } from "@/components/ui/input";
@@ -81,7 +82,7 @@ export function ConnectMcpServerDialog({ open, onOpenChange, onCreated }: Connec
       reset();
       onCreated();
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("tools.failedToConnectMcp"));
+      toast.error(translateApiError(err));
     } finally {
       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 { Globe2, Plug, Search } from "lucide-react";
 import { createTool } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { Button } from "@/components/ui/button";
 import { Dialog } from "@/components/ui/dialog";
 import { Input, Textarea } from "@/components/ui/input";
@@ -43,7 +44,7 @@ export function CreateToolDialog({ open, onOpenChange, onCreated }: CreateToolDi
       setDescription("");
       onCreated();
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : "Failed to create tool");
+      toast.error(translateApiError(err));
     } finally {
       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 { useTranslation } from "react-i18next";
 import { discoverMcpConnection, listToolConnections } from "@/api";
+import { translateApiError } from "@/api/errors";
 import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
 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)));
       toast.success(t("tools.discoveryCompleted", "MCP discovery completed"));
     } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("tools.failedToDiscoverMcp", "Failed to discover MCP tools"));
+      toast.error(translateApiError(err));
     } finally {
       setDiscovering(false);
     }