Add native Didar connectors so chats can query CRM data reliably.
Connector calls, semantic rules, and DuckDB memory keep long runs stable, and stream retry stops chats from stalling. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
228324db7b
commit
edbaa2acec
82 files changed
+13427
-203
No files matched your search
@@ -5,6 +5,7 @@ from fastapi import FastAPI
|
||||
from kamidso.api.routes import (
|
||||
auth,
|
||||
chat,
|
||||
connectors,
|
||||
conversations,
|
||||
datasources,
|
||||
files,
|
||||
@@ -26,6 +27,7 @@ def register_routes(app: FastAPI, *, prefix: str) -> None:
|
||||
app.include_router(runs.router, prefix=prefix, tags=["runs"])
|
||||
app.include_router(files.router, prefix=prefix, tags=["files"])
|
||||
app.include_router(datasources.router, prefix=prefix, tags=["datasources"])
|
||||
app.include_router(connectors.router, prefix=prefix, tags=["connectors"])
|
||||
app.include_router(knowledge.router, prefix=prefix, tags=["knowledge"])
|
||||
app.include_router(resources.router, prefix=prefix, tags=["resources"])
|
||||
app.include_router(organization.router, prefix=prefix, tags=["organization"])
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Product API for predefined native connectors. Users cannot define providers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from kamidso.api.deps import require_user
|
||||
from kamidso.engine.serve.core import blocking_func_to_async
|
||||
from kamidso.native_connectors.flags import (
|
||||
didar_connector_enabled,
|
||||
native_connectors_v1_enabled,
|
||||
)
|
||||
from kamidso.native_connectors.ux import (
|
||||
connector_test_message,
|
||||
public_connector,
|
||||
public_provider,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _ok(data: Any) -> Dict[str, Any]:
|
||||
return {"success": True, "data": data, "err_code": None, "err_msg": None}
|
||||
|
||||
|
||||
def _native_enabled() -> bool:
|
||||
return native_connectors_v1_enabled() and didar_connector_enabled()
|
||||
|
||||
|
||||
def _service(request: Request):
|
||||
from kamidso.engine.serve.connector.config import SERVE_SERVICE_COMPONENT_NAME
|
||||
from kamidso.engine.serve.connector.service.service import ConnectorService
|
||||
|
||||
runtime = getattr(request.app.state.kamidso, "engine_runtime", None)
|
||||
system_app = getattr(runtime, "system_app", None) if runtime is not None else None
|
||||
if system_app is None:
|
||||
raise HTTPException(status_code=503, detail="موتور اتصالها آماده نیست.")
|
||||
service = system_app.get_component(
|
||||
SERVE_SERVICE_COMPONENT_NAME,
|
||||
ConnectorService,
|
||||
default_component=None,
|
||||
)
|
||||
if service is None:
|
||||
raise HTTPException(status_code=503, detail="موتور اتصالها آماده نیست.")
|
||||
return service, system_app
|
||||
|
||||
|
||||
def _owned(service, connector_id: str, user):
|
||||
row = service.get_connector(connector_id)
|
||||
if row is None or row.status == "deleted":
|
||||
raise HTTPException(status_code=404, detail="اتصال پیدا نشد.")
|
||||
if row.user_name != user.id:
|
||||
raise HTTPException(status_code=404, detail="اتصال پیدا نشد.")
|
||||
if user.workspace_id and row.sys_code and row.sys_code != user.workspace_id:
|
||||
raise HTTPException(status_code=404, detail="اتصال پیدا نشد.")
|
||||
if not service._is_native_type(row.connector_type):
|
||||
raise HTTPException(status_code=404, detail="اتصال پیدا نشد.")
|
||||
return row
|
||||
|
||||
|
||||
class ConnectorCreateBody(BaseModel):
|
||||
connector_type: str
|
||||
display_name: str
|
||||
credentials: Dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ConnectorTestBody(BaseModel):
|
||||
connector_type: str
|
||||
credentials: Dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ConnectorUpdateBody(BaseModel):
|
||||
display_name: Optional[str] = None
|
||||
credentials: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
def _probe_http(exc: Exception) -> HTTPException:
|
||||
from kamidso.engine.serve.connector.service.service import ConnectorProbeFailed
|
||||
|
||||
if isinstance(exc, ConnectorProbeFailed):
|
||||
public = connector_test_message(
|
||||
{
|
||||
"success": False,
|
||||
"status_code": exc.status_code,
|
||||
"error_type": exc.error_type,
|
||||
}
|
||||
)
|
||||
return HTTPException(status_code=400, detail=public["message"])
|
||||
text = str(exc)
|
||||
if text.startswith("Missing credential"):
|
||||
return HTTPException(status_code=400, detail="کلید API را وارد کنید.")
|
||||
if "ENCRYPT_KEY" in text:
|
||||
return HTTPException(
|
||||
status_code=400,
|
||||
detail="ذخیرهٔ امن کلید API در این محیط پیکربندی نشده است.",
|
||||
)
|
||||
if text == "Unknown native provider":
|
||||
return HTTPException(status_code=404, detail="این اتصال در دسترس نیست.")
|
||||
return HTTPException(status_code=400, detail="ارتباط با دیدار برقرار نشد.")
|
||||
|
||||
|
||||
@router.get("/connectors/providers")
|
||||
async def list_providers(request: Request):
|
||||
require_user(request)
|
||||
if not _native_enabled():
|
||||
return _ok([])
|
||||
from kamidso.native_connectors.registry import get_registry
|
||||
|
||||
registry = get_registry()
|
||||
return _ok([public_provider(registry.get(item)) for item in registry.provider_ids()])
|
||||
|
||||
|
||||
@router.get("/connectors")
|
||||
async def list_user_connectors(request: Request):
|
||||
user = require_user(request)
|
||||
if not _native_enabled():
|
||||
return _ok([])
|
||||
service, _system = _service(request)
|
||||
rows = service.list_connectors(
|
||||
user_name=user.id,
|
||||
sys_code=user.workspace_id or None,
|
||||
)
|
||||
visible: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
if row.status == "deleted" or not service._is_native_type(row.connector_type):
|
||||
continue
|
||||
visible.append(public_connector(row))
|
||||
return _ok(visible)
|
||||
|
||||
|
||||
@router.post("/connectors/test")
|
||||
async def test_new_connector(body: ConnectorTestBody, request: Request):
|
||||
require_user(request)
|
||||
if not _native_enabled():
|
||||
raise HTTPException(status_code=404, detail="این اتصال در دسترس نیست.")
|
||||
service, _system = _service(request)
|
||||
try:
|
||||
raw = await service.test_native_credentials(body.connector_type, body.credentials)
|
||||
except Exception as exc:
|
||||
logger.exception("Native connector probe failed")
|
||||
raise _probe_http(exc) from exc
|
||||
return _ok(connector_test_message(raw))
|
||||
|
||||
|
||||
@router.post("/connectors")
|
||||
async def create_user_connector(body: ConnectorCreateBody, request: Request):
|
||||
user = require_user(request)
|
||||
if not _native_enabled():
|
||||
raise HTTPException(status_code=404, detail="این اتصال در دسترس نیست.")
|
||||
from kamidso.engine.serve.connector.service.service import ConnectorCreateRequest
|
||||
|
||||
service, system_app = _service(request)
|
||||
if not service._is_native_type(body.connector_type):
|
||||
raise HTTPException(status_code=404, detail="این اتصال در دسترس نیست.")
|
||||
request_model = ConnectorCreateRequest(
|
||||
connector_type=body.connector_type,
|
||||
display_name=body.display_name.strip() or "دیدار CRM",
|
||||
credentials=dict(body.credentials),
|
||||
user_name=user.id,
|
||||
sys_code=user.workspace_id or None,
|
||||
)
|
||||
try:
|
||||
created = await blocking_func_to_async(
|
||||
system_app, service.create_connector, request_model
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Create native connector failed")
|
||||
raise _probe_http(exc) from exc
|
||||
return _ok(public_connector(created))
|
||||
|
||||
|
||||
@router.patch("/connectors/{connector_id}")
|
||||
async def update_user_connector(
|
||||
connector_id: str, body: ConnectorUpdateBody, request: Request
|
||||
):
|
||||
user = require_user(request)
|
||||
from kamidso.engine.serve.connector.service.service import ConnectorUpdateRequest
|
||||
|
||||
service, system_app = _service(request)
|
||||
_owned(service, connector_id, user)
|
||||
credentials = None
|
||||
if body.credentials:
|
||||
filled = {
|
||||
key: value
|
||||
for key, value in body.credentials.items()
|
||||
if str(value).strip()
|
||||
}
|
||||
credentials = filled or None
|
||||
update = ConnectorUpdateRequest(
|
||||
display_name=body.display_name.strip() if body.display_name else None,
|
||||
credentials=credentials,
|
||||
)
|
||||
try:
|
||||
updated = await blocking_func_to_async(
|
||||
system_app, service.update_connector, connector_id, update
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Update native connector failed")
|
||||
raise _probe_http(exc) from exc
|
||||
return _ok(public_connector(updated))
|
||||
|
||||
|
||||
@router.delete("/connectors/{connector_id}")
|
||||
async def delete_user_connector(connector_id: str, request: Request):
|
||||
user = require_user(request)
|
||||
service, system_app = _service(request)
|
||||
_owned(service, connector_id, user)
|
||||
await blocking_func_to_async(system_app, service.retire_connector, connector_id)
|
||||
return _ok({"ok": True})
|
||||
|
||||
|
||||
@router.post("/connectors/{connector_id}/test")
|
||||
async def test_saved_connector(connector_id: str, request: Request):
|
||||
user = require_user(request)
|
||||
service, _system = _service(request)
|
||||
_owned(service, connector_id, user)
|
||||
raw = await service.test_connection(connector_id)
|
||||
return _ok(connector_test_message(raw))
|
||||
@@ -5,6 +5,19 @@ from fastapi import APIRouter, Request
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _features_payload(settings) -> dict:
|
||||
flags = settings.features.to_dict()
|
||||
from kamidso.native_connectors.flags import (
|
||||
didar_connector_enabled,
|
||||
native_connectors_v1_enabled,
|
||||
)
|
||||
|
||||
flags["connectors"] = bool(
|
||||
native_connectors_v1_enabled() and didar_connector_enabled()
|
||||
)
|
||||
return flags
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health(request: Request):
|
||||
app = request.app.state.kamidso
|
||||
@@ -25,7 +38,7 @@ async def product(request: Request):
|
||||
"product": settings.product.name,
|
||||
"locale": settings.product.locale,
|
||||
"direction": settings.product.direction,
|
||||
"features": settings.features.to_dict(),
|
||||
"features": _features_payload(settings),
|
||||
"developer_mode": settings.developer_mode,
|
||||
}
|
||||
|
||||
@@ -34,7 +47,7 @@ async def product(request: Request):
|
||||
async def features(request: Request):
|
||||
settings = request.app.state.kamidso.settings
|
||||
user = getattr(request.state, "kamidso_user", None)
|
||||
flags = settings.features.to_dict()
|
||||
flags = _features_payload(settings)
|
||||
role = user.role if user else "user"
|
||||
if role == "user" and not settings.developer_mode:
|
||||
flags["apps"] = False
|
||||
|
||||
+2
-1
@@ -5,9 +5,10 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from typing import AsyncIterator
|
||||
|
||||
# Do not send Connection. It is hop-by-hop; proxies that honor it reuse the
|
||||
# socket while the body is still streaming and the next chat drops both streams.
|
||||
SSE_HEADERS = {
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,12 @@ class KamidsoContextBuilder:
|
||||
skills.append(skill)
|
||||
|
||||
behavior = DEFAULT_BEHAVIOR
|
||||
connector_ids = ext_info.get("connector_ids")
|
||||
attached_connectors = (
|
||||
[str(item).strip() for item in connector_ids if str(item).strip()]
|
||||
if isinstance(connector_ids, list)
|
||||
else []
|
||||
)
|
||||
kb_label = str(ext_info.get("knowledge_space_label") or (knowledge[0] if knowledge else "")).strip()
|
||||
if knowledge:
|
||||
behavior += (
|
||||
@@ -135,13 +141,40 @@ class KamidsoContextBuilder:
|
||||
behavior += (
|
||||
f" Optional picker hint: `{skill}` — load it only if it fits."
|
||||
)
|
||||
else:
|
||||
elif not attached_connectors:
|
||||
behavior += (
|
||||
"\nNo file or database is attached this turn. "
|
||||
"Do not reopen Excel/CSV files from earlier chats "
|
||||
"(data/runtime, data/uploads, previous run folders). "
|
||||
"If the question needs data, call the question tool and ask which file or database to use."
|
||||
)
|
||||
if attached_connectors:
|
||||
behavior += (
|
||||
"\nA native business connector is attached. "
|
||||
"Use connector_call to fetch business data. "
|
||||
"Do not use code_interpreter to call the external API. "
|
||||
"Use code_interpreter only after connector_call when computation, "
|
||||
"dataframe operations, merging, charts, statistics or export are required. "
|
||||
"Never invent connector function names. "
|
||||
"On validation_error, correct the request using the returned schema "
|
||||
"and retry the same function. "
|
||||
"Prefer server-provided totals and aggregates. "
|
||||
"Do not paginate through all records merely to count them."
|
||||
)
|
||||
if datasources or files:
|
||||
behavior += (
|
||||
" Didar and other native connectors use connector_call. "
|
||||
"Databases use database(). Excel and CSV files use code_interpreter. "
|
||||
"Join the results in code_interpreter after each source has been read."
|
||||
)
|
||||
labels = ext_info.get("connector_labels")
|
||||
if isinstance(labels, list) and any(str(item).strip() for item in labels):
|
||||
shown = ", ".join(str(item).strip() for item in labels if str(item).strip())
|
||||
else:
|
||||
shown = ", ".join(attached_connectors)
|
||||
behavior += (
|
||||
f" Attached connector{'s' if len(attached_connectors) > 1 else ''}: {shown}."
|
||||
)
|
||||
if skill:
|
||||
behavior += (
|
||||
f"\nThe user highlighted skill `{skill}` in the picker. "
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any, Dict, Literal, Optional
|
||||
|
||||
ResourceType = Literal["file", "datasource", "knowledge"]
|
||||
ResourceType = Literal["file", "datasource", "knowledge", "connector"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -87,7 +87,6 @@ async def list_edges(request: Request):
|
||||
|
||||
_SSE_HEADERS = {
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,13 @@ from kamidso.engine.app.openapi.api_v1.native_dbgpt import (
|
||||
prepare_react_gpts_memory, # noqa: F401 — re-export for tests
|
||||
use_native_dbgpt_prompt,
|
||||
)
|
||||
from kamidso.engine.app.openapi.api_v1.react_runtime import (
|
||||
REACT_AGENT_MEMORY_CACHE,
|
||||
REACT_CONVERSATION_AGENT_TASKS,
|
||||
REACT_CONVERSATION_LOCKS,
|
||||
acquire_conversation_run,
|
||||
end_react_turn,
|
||||
)
|
||||
from kamidso.engine.app.openapi.api_view_model import (
|
||||
ConversationVo,
|
||||
Result,
|
||||
@@ -84,12 +91,6 @@ if TYPE_CHECKING:
|
||||
from kamidso.engine.core.agent.resource.connector.manager import ConnectorManager
|
||||
from kamidso.engine.core.agent.resource.tool.base import BaseTool
|
||||
|
||||
REACT_AGENT_MEMORY_CACHE: Dict[str, "GptsMemory"] = {}
|
||||
# Serialize ReAct runs per conversation. A second message is rejected while a
|
||||
# run is live; explicit cancel uses REACT_CONVERSATION_AGENT_TASKS.
|
||||
REACT_CONVERSATION_LOCKS: Dict[str, asyncio.Lock] = {}
|
||||
REACT_CONVERSATION_AGENT_TASKS: Dict[str, "asyncio.Task[Any]"] = {}
|
||||
|
||||
_FATAL_LLM_ERROR_MARKERS = ("LLMServer Generate Error", "proxy_llm_client")
|
||||
_REACT_SCRATCHPAD_MARKERS = ("Thought:", "Action:", "Action Input:", "Observation:")
|
||||
|
||||
@@ -2133,64 +2134,64 @@ async def _react_agent_stream_inner(
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Stream ReAct events while owning the lifetime of its background task."""
|
||||
conv_id = getattr(dialogue, "conv_uid", None) or ""
|
||||
conv_lock = _conversation_agent_lock(conv_id) if conv_id else None
|
||||
if conv_lock is not None:
|
||||
await conv_lock.acquire()
|
||||
# Same conversation is serialized by the lock above. Do not cancel a live
|
||||
# agent for another turn — the orchestrator rejects a second message while
|
||||
# a run is still active. Explicit cancel uses _cancel_conversation_agent.
|
||||
|
||||
agent_task_holder: List["asyncio.Task[Any]"] = []
|
||||
final_emitted = False
|
||||
done_emitted = False
|
||||
from kamidso.engine.app.openapi.api_v1.tools.skill_runtime import (
|
||||
bind_shared_execution,
|
||||
empty_skill_execution,
|
||||
reset_shared_execution,
|
||||
)
|
||||
from kamidso.engine.core.reasoning_policy import bind_turn, reset_turn
|
||||
|
||||
ext_info_raw = getattr(dialogue, "ext_info", None)
|
||||
ext_info = ext_info_raw if isinstance(ext_info_raw, dict) else {}
|
||||
reasoning_token = bind_turn(ext_info.get("reasoning_mode") or "fast")
|
||||
execution_token = bind_shared_execution(empty_skill_execution())
|
||||
# Same conversation is serialized by this lock. A second message waits on
|
||||
# the same lock object; the lock is removed only after every waiter exits.
|
||||
# Explicit cancel uses _cancel_conversation_agent.
|
||||
conv_lock = await acquire_conversation_run(conv_id) if conv_id else None
|
||||
try:
|
||||
async for event in _react_agent_stream_impl(
|
||||
dialogue,
|
||||
tool_mode=tool_mode,
|
||||
attachment_ctx=attachment_ctx,
|
||||
agent_task_holder=agent_task_holder,
|
||||
):
|
||||
event_type = _sse_event_type(event)
|
||||
final_emitted = final_emitted or event_type == "final"
|
||||
done_emitted = done_emitted or event_type == "done"
|
||||
yield event
|
||||
except Exception:
|
||||
logger.exception("ReAct agent stream failed before normal completion")
|
||||
if not final_emitted and not done_emitted:
|
||||
yield _sse_event(
|
||||
AgentFinalAnswer(
|
||||
content=_stream_failure_user_message(ext_info)
|
||||
).to_sse_payload()
|
||||
)
|
||||
if not done_emitted:
|
||||
yield _sse_event({"type": "done"})
|
||||
agent_task_holder: List["asyncio.Task[Any]"] = []
|
||||
final_emitted = False
|
||||
done_emitted = False
|
||||
from kamidso.engine.app.openapi.api_v1.tools.skill_runtime import (
|
||||
bind_shared_execution,
|
||||
empty_skill_execution,
|
||||
reset_shared_execution,
|
||||
)
|
||||
from kamidso.engine.core.reasoning_policy import bind_turn, reset_turn
|
||||
|
||||
ext_info_raw = getattr(dialogue, "ext_info", None)
|
||||
ext_info = ext_info_raw if isinstance(ext_info_raw, dict) else {}
|
||||
reasoning_token = bind_turn(ext_info.get("reasoning_mode") or "fast")
|
||||
execution_token = bind_shared_execution(empty_skill_execution())
|
||||
try:
|
||||
async for event in _react_agent_stream_impl(
|
||||
dialogue,
|
||||
tool_mode=tool_mode,
|
||||
attachment_ctx=attachment_ctx,
|
||||
agent_task_holder=agent_task_holder,
|
||||
):
|
||||
event_type = _sse_event_type(event)
|
||||
final_emitted = final_emitted or event_type == "final"
|
||||
done_emitted = done_emitted or event_type == "done"
|
||||
yield event
|
||||
except Exception:
|
||||
logger.exception("ReAct agent stream failed before normal completion")
|
||||
if not final_emitted and not done_emitted:
|
||||
yield _sse_event(
|
||||
AgentFinalAnswer(
|
||||
content=_stream_failure_user_message(ext_info)
|
||||
).to_sse_payload()
|
||||
)
|
||||
if not done_emitted:
|
||||
yield _sse_event({"type": "done"})
|
||||
finally:
|
||||
try:
|
||||
reset_turn(reasoning_token)
|
||||
except (ValueError, LookupError):
|
||||
logger.debug("reasoning turn token already reset", exc_info=True)
|
||||
try:
|
||||
reset_shared_execution(execution_token)
|
||||
except (ValueError, LookupError):
|
||||
logger.debug("skill execution token already reset", exc_info=True)
|
||||
if agent_task_holder:
|
||||
task = agent_task_holder[0]
|
||||
if conv_id and REACT_CONVERSATION_AGENT_TASKS.get(conv_id) is task:
|
||||
REACT_CONVERSATION_AGENT_TASKS.pop(conv_id, None)
|
||||
await _cancel_and_await_agent_task(task)
|
||||
finally:
|
||||
try:
|
||||
reset_turn(reasoning_token)
|
||||
except (ValueError, LookupError):
|
||||
logger.debug("reasoning turn token already reset", exc_info=True)
|
||||
try:
|
||||
reset_shared_execution(execution_token)
|
||||
except (ValueError, LookupError):
|
||||
logger.debug("skill execution token already reset", exc_info=True)
|
||||
if agent_task_holder:
|
||||
task = agent_task_holder[0]
|
||||
if conv_id and REACT_CONVERSATION_AGENT_TASKS.get(conv_id) is task:
|
||||
REACT_CONVERSATION_AGENT_TASKS.pop(conv_id, None)
|
||||
await _cancel_and_await_agent_task(task)
|
||||
if conv_lock is not None:
|
||||
conv_lock.release()
|
||||
# History persistence runs inside the stream body, before this
|
||||
# finally. Scratch memory is dropped while the lock is still held.
|
||||
await end_react_turn(conv_id, conv_lock)
|
||||
|
||||
|
||||
class _AgentStreamingResponse(StreamingResponse):
|
||||
@@ -2264,6 +2265,20 @@ async def _react_agent_stream_impl(
|
||||
|
||||
# Connector selection (Task C): only inject user-selected connectors.
|
||||
connector_ids: List[str] = _parse_connector_ids(dialogue.ext_info)
|
||||
mcp_connector_ids = list(connector_ids)
|
||||
native_connector_instances: List[Dict[str, Any]] = []
|
||||
try:
|
||||
from kamidso.native_connectors.flags import native_connectors_v1_enabled
|
||||
from kamidso.native_connectors.resolve import partition_connector_ids
|
||||
|
||||
if native_connectors_v1_enabled() and connector_ids:
|
||||
mcp_connector_ids, native_connector_instances = partition_connector_ids(
|
||||
connector_ids
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("native connector partition failed")
|
||||
mcp_connector_ids = list(connector_ids)
|
||||
native_connector_instances = []
|
||||
|
||||
code_graph_available = False
|
||||
if knowledge_space:
|
||||
@@ -2652,6 +2667,10 @@ async def _react_agent_stream_impl(
|
||||
"database_connector_labels": database_connector_labels,
|
||||
"db_connection_failures": int(db_failure_state.get("db_connection_failures") or 0),
|
||||
}
|
||||
if native_connector_instances:
|
||||
from kamidso.native_connectors.resolve import install_native_connectors
|
||||
|
||||
install_native_connectors(react_state, native_connector_instances)
|
||||
if db_failure_state.get("db_connection_give_up"):
|
||||
react_state["db_connection_give_up"] = db_failure_state["db_connection_give_up"]
|
||||
from kamidso.assistant.data_sketch import compose_turn_data_sketch, paths_from_ext_info
|
||||
@@ -3126,7 +3145,18 @@ print(json.dumps(summary, ensure_ascii=False))
|
||||
react_state,
|
||||
sql_enabled=_this_turn_allows_sql_query(attached_dbs),
|
||||
database_runtime=_this_turn_uses_db_runtime(attached_dbs),
|
||||
native_connector=bool(native_connector_instances),
|
||||
)
|
||||
connector_call_tool = None
|
||||
if native_connector_instances and tool_mode != "knowledge":
|
||||
from kamidso.engine.app.openapi.api_v1.tools.connector_call import (
|
||||
make_connector_call,
|
||||
)
|
||||
|
||||
connector_call_tool = make_connector_call(
|
||||
react_state,
|
||||
user_name=dialogue.user_name,
|
||||
)
|
||||
shell_interpreter_tool = make_shell_interpreter(react_state)
|
||||
html_interpreter_tool = make_html_interpreter(react_state, DEFAULT_SKILLS_DIR)
|
||||
todowrite_tool = make_todowrite(_todo_list, stream_callback)
|
||||
@@ -3639,9 +3669,9 @@ print(json.dumps(summary, ensure_ascii=False))
|
||||
_connector_manager = CFG.SYSTEM_APP.get_component(
|
||||
"connector_manager", _ConnectorManager, default_component=None
|
||||
)
|
||||
if _connector_manager is not None and connector_ids:
|
||||
if _connector_manager is not None and mcp_connector_ids:
|
||||
connector_tool_extras, _missing = _select_connector_tools(
|
||||
connector_ids, _connector_manager
|
||||
mcp_connector_ids, _connector_manager
|
||||
)
|
||||
for _mid in _missing:
|
||||
logger.warning(
|
||||
@@ -3653,7 +3683,7 @@ print(json.dumps(summary, ensure_ascii=False))
|
||||
"_react_agent_stream: injected %d connector tool pack(s) "
|
||||
"(selected: %d)",
|
||||
len(connector_tool_extras),
|
||||
len(connector_ids),
|
||||
len(mcp_connector_ids),
|
||||
)
|
||||
except Exception:
|
||||
pass # graceful degradation — connector tools are optional
|
||||
@@ -3686,9 +3716,19 @@ print(json.dumps(summary, ensure_ascii=False))
|
||||
emit_event=_emit_subagent_event,
|
||||
max_parallel=_max_parallel_subagents,
|
||||
report_mode=report_mode,
|
||||
native_connectors=native_connector_instances,
|
||||
user_name=dialogue.user_name,
|
||||
)
|
||||
|
||||
_native_prompt = use_native_dbgpt_prompt()
|
||||
native_section = ""
|
||||
if native_connector_instances:
|
||||
try:
|
||||
from kamidso.native_connectors.prompt import render_native_prompt
|
||||
|
||||
native_section = render_native_prompt(native_connector_instances)
|
||||
except Exception:
|
||||
logger.exception("native connector prompt failed")
|
||||
if is_skill_mode:
|
||||
if attached_dbs and not has_attached_files:
|
||||
skill_compute_rule = (
|
||||
@@ -3842,8 +3882,10 @@ Never use Chinese in any user-visible text.
|
||||
execute_skill_script,
|
||||
get_skill_resource,
|
||||
execute_skill_script_file_tool,
|
||||
code_interpreter_tool,
|
||||
]
|
||||
if connector_call_tool is not None:
|
||||
skill_mode_tools.append(connector_call_tool)
|
||||
skill_mode_tools.append(code_interpreter_tool)
|
||||
if has_attached_files:
|
||||
skill_mode_tools.append(load_file_tool)
|
||||
skill_mode_tools.append(shell_interpreter_tool)
|
||||
@@ -4044,11 +4086,17 @@ Never use Chinese in any user-visible text.
|
||||
execute_skill_script,
|
||||
get_skill_resource,
|
||||
execute_skill_script_file_tool,
|
||||
code_interpreter_tool,
|
||||
load_file_tool,
|
||||
execute_analysis_tool,
|
||||
shell_interpreter_tool,
|
||||
]
|
||||
if connector_call_tool is not None:
|
||||
analysis_tools.append(connector_call_tool)
|
||||
analysis_tools.extend(
|
||||
[
|
||||
code_interpreter_tool,
|
||||
load_file_tool,
|
||||
execute_analysis_tool,
|
||||
shell_interpreter_tool,
|
||||
]
|
||||
)
|
||||
if report_mode:
|
||||
analysis_tools.append(html_interpreter_tool)
|
||||
if expose_sql_query:
|
||||
@@ -4095,14 +4143,13 @@ Never use Chinese in any user-visible text.
|
||||
_cm = CFG.SYSTEM_APP.get_component(
|
||||
"connector_manager", _ConnectorManager, default_component=None
|
||||
)
|
||||
if _cm is not None and connector_ids:
|
||||
if _cm is not None and mcp_connector_ids:
|
||||
_active = _cm.list_active()
|
||||
# Only describe connectors the user explicitly selected.
|
||||
# Iterate connector_ids (not _active) so prompt order matches
|
||||
# user selection order and stays consistent with _select_connector_tools.
|
||||
# Iterate mcp ids so native connectors stay out of this block.
|
||||
_active_map = {c["connector_id"]: c for c in _active if isinstance(c, dict)}
|
||||
_selected = [
|
||||
_active_map[cid] for cid in connector_ids if cid in _active_map
|
||||
_active_map[cid] for cid in mcp_connector_ids if cid in _active_map
|
||||
]
|
||||
if _selected:
|
||||
_connector_lines = []
|
||||
@@ -4211,6 +4258,9 @@ Never use Chinese in any user-visible text.
|
||||
Never invent the meaning of an unresolved field.
|
||||
"""
|
||||
|
||||
if native_section:
|
||||
workflow_prompt += "\n\n" + native_section.replace("{", "{{").replace("}", "}}")
|
||||
|
||||
workflow_prompt = (
|
||||
f"{workflow_prompt.rstrip()}\n\n"
|
||||
f"{_MULTI_TURN_PROMPT_SECTION}\n\n"
|
||||
@@ -4235,7 +4285,11 @@ Never use Chinese in any user-visible text.
|
||||
.bind(llm_config)
|
||||
.bind(tool_pack)
|
||||
)
|
||||
agent_builder = bind_agent_prompt(agent_builder, workflow_prompt_template)
|
||||
agent_builder = bind_agent_prompt(
|
||||
agent_builder,
|
||||
workflow_prompt_template,
|
||||
extra_goal=native_section,
|
||||
)
|
||||
|
||||
agent = await agent_builder.build()
|
||||
|
||||
@@ -4348,6 +4402,7 @@ Never use Chinese in any user-visible text.
|
||||
"agent.start",
|
||||
"agent.done",
|
||||
"agent.step",
|
||||
"agent.thinking",
|
||||
"subagent.artifacts",
|
||||
):
|
||||
# Sub-agent progress uses a separate event channel so parallel
|
||||
@@ -5216,7 +5271,7 @@ async def chat_react_agent(
|
||||
headers = {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Transfer-Encoding": "chunked",
|
||||
}
|
||||
try:
|
||||
@@ -5264,7 +5319,7 @@ async def chat_knowledge_agent(
|
||||
headers = {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Transfer-Encoding": "chunked",
|
||||
}
|
||||
try:
|
||||
|
||||
@@ -543,7 +543,7 @@ async def chat_completions(
|
||||
headers = {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Transfer-Encoding": "chunked",
|
||||
}
|
||||
try:
|
||||
|
||||
@@ -125,7 +125,9 @@ def prepare_react_gpts_memory(cache: Dict[str, GptsMemory], conv_id: str) -> Gpt
|
||||
|
||||
Native mode uses persistent MetaDb backends for both messages and plans.
|
||||
Legacy mode keeps in-process DefaultGptsPlansMemory + MetaDb messages.
|
||||
``init`` runs only on cache miss so later user turns keep the scratchpad.
|
||||
``init`` runs only on cache miss. A finished Kamidso turn drops this entry
|
||||
with ``dispose_runtime``; the next turn builds a new scratch and reloads
|
||||
the needed history from MetaDB and ``kamidso_history``.
|
||||
"""
|
||||
existing = cache.get(conv_id)
|
||||
if existing is not None:
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""In-process lifetime of one ReAct turn.
|
||||
|
||||
The dictionaries below used to live for the whole backend process. A finished
|
||||
turn drops its scratch memory and its conversation lock only after every
|
||||
waiter for that conversation has left.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, Optional
|
||||
|
||||
REACT_AGENT_MEMORY_CACHE: Dict[str, object] = {}
|
||||
# Serialize ReAct runs per conversation. A second message waits on the same
|
||||
# lock; explicit cancel uses REACT_CONVERSATION_AGENT_TASKS.
|
||||
REACT_CONVERSATION_LOCKS: Dict[str, asyncio.Lock] = {}
|
||||
REACT_CONVERSATION_AGENT_TASKS: Dict[str, "asyncio.Task[object]"] = {}
|
||||
|
||||
_LOCK_GUARD = asyncio.Lock()
|
||||
_LOCK_REFS: Dict[str, int] = {}
|
||||
|
||||
|
||||
async def acquire_conversation_run(conv_id: str) -> asyncio.Lock:
|
||||
"""Take the per-conversation lock and count this waiter.
|
||||
|
||||
The refcount is incremented before waiting, so a finishing turn cannot
|
||||
delete the lock out from under someone already queued on it.
|
||||
"""
|
||||
async with _LOCK_GUARD:
|
||||
lock = REACT_CONVERSATION_LOCKS.get(conv_id)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
REACT_CONVERSATION_LOCKS[conv_id] = lock
|
||||
_LOCK_REFS[conv_id] = _LOCK_REFS.get(conv_id, 0) + 1
|
||||
try:
|
||||
await lock.acquire()
|
||||
except BaseException:
|
||||
# Cancelled while queued. The lock was not taken; drop the reservation
|
||||
# so a finished turn can still remove it once every other waiter is gone.
|
||||
await _drop_reservation(conv_id, lock)
|
||||
raise
|
||||
return lock
|
||||
|
||||
|
||||
async def release_conversation_run(conv_id: str, lock: asyncio.Lock) -> None:
|
||||
"""Release one holder. Drop the lock only when nobody else is waiting."""
|
||||
lock.release()
|
||||
await _drop_reservation(conv_id, lock)
|
||||
|
||||
|
||||
async def _drop_reservation(conv_id: str, lock: asyncio.Lock) -> None:
|
||||
"""Forget one reserved waiter. Keep the lock while anyone else still needs it."""
|
||||
async with _LOCK_GUARD:
|
||||
refs = _LOCK_REFS.get(conv_id, 1) - 1
|
||||
current = REACT_CONVERSATION_LOCKS.get(conv_id)
|
||||
if refs <= 0 and current is lock and not lock.locked():
|
||||
REACT_CONVERSATION_LOCKS.pop(conv_id, None)
|
||||
_LOCK_REFS.pop(conv_id, None)
|
||||
else:
|
||||
_LOCK_REFS[conv_id] = max(refs, 0)
|
||||
|
||||
|
||||
def release_cached_gpts_memory(conv_id: str) -> None:
|
||||
"""Drop the turn's GptsMemory scratch. Persisted history is not deleted."""
|
||||
memory = REACT_AGENT_MEMORY_CACHE.pop(conv_id, None)
|
||||
if memory is None:
|
||||
return
|
||||
dispose = getattr(memory, "dispose_runtime", None)
|
||||
if callable(dispose):
|
||||
dispose(conv_id)
|
||||
|
||||
|
||||
async def end_react_turn(conv_id: str, lock: Optional[asyncio.Lock]) -> None:
|
||||
"""Free scratch memory, then release the conversation lock.
|
||||
|
||||
Call this from the stream ``finally`` after the agent task has finished
|
||||
and after ``_react_terminal_events`` has already persisted the round.
|
||||
The lock is still held while the cache is removed, so the next waiter
|
||||
cannot enter until this turn's scratch is gone.
|
||||
"""
|
||||
try:
|
||||
if conv_id:
|
||||
release_cached_gpts_memory(conv_id)
|
||||
finally:
|
||||
if lock is not None and conv_id:
|
||||
await release_conversation_run(conv_id, lock)
|
||||
|
||||
|
||||
def conversation_lock_refcount(conv_id: str) -> int:
|
||||
"""Test helper: holders plus waiters that have already reserved the lock."""
|
||||
return _LOCK_REFS.get(conv_id, 0)
|
||||
@@ -34,6 +34,7 @@ from kamidso.engine.app.openapi.api_v1.native_dbgpt import (
|
||||
build_agent_memory,
|
||||
op_snapshot_dir,
|
||||
)
|
||||
from kamidso.engine.app.openapi.api_v1.thinking_stream import live_thinking_display
|
||||
from kamidso.engine.core._private.config import Config
|
||||
from kamidso.engine.core.agent.expand.actions.react_action import Terminate
|
||||
from kamidso.engine.core.agent.resource import ToolPack, tool
|
||||
@@ -211,6 +212,11 @@ Use the database / knowledge base / MCP connector named in your goal.
|
||||
- You CANNOT delegate further (no parallel sub-tasks of your own).
|
||||
- Keep your context tight; produce a concise, self-contained conclusion.
|
||||
- When done, call ``terminate`` with your final result.
|
||||
- If ``connector_call`` is in the action space, CRM and other business-app data
|
||||
MUST be read with ``connector_call`` (semantic operation or the function named
|
||||
in the goal). Never call ``http://127.0.0.1``, ``http://localhost``, or invent
|
||||
paths such as ``/api/crm``, ``/api/didar``, or ``/search_deals``. Those hit
|
||||
this application and return 404; they are not the business API.
|
||||
|
||||
## Output formatting (IMPORTANT — keep results readable)
|
||||
- One query/result per step. Prefer separate ``database().query`` calls inside
|
||||
@@ -324,6 +330,8 @@ async def build_sub_react_agent(
|
||||
extra_context: Optional[str] = None,
|
||||
batch_id: int = 0,
|
||||
report_mode: bool = False,
|
||||
native_connectors: Optional[List[Any]] = None,
|
||||
user_name: Optional[str] = None,
|
||||
):
|
||||
"""Construct an isolated child ReActAgent for one sub-task.
|
||||
|
||||
@@ -359,6 +367,10 @@ async def build_sub_react_agent(
|
||||
# (2) Independent react_state — artifact bookkeeping isolation. A fresh
|
||||
# dict means generated_images / image_url_map etc. never cross-write.
|
||||
sub_state = {"conv_id": sub_conv_id, "file_path": None}
|
||||
if native_connectors:
|
||||
from kamidso.native_connectors.resolve import install_native_connectors
|
||||
|
||||
install_native_connectors(sub_state, native_connectors)
|
||||
|
||||
# (3) Independent AgentMemory — not cached in REACT_AGENT_MEMORY_CACHE,
|
||||
# so the sub-agent's history never pollutes the parent's memory.
|
||||
@@ -377,9 +389,17 @@ async def build_sub_react_agent(
|
||||
database_connector=database_connector,
|
||||
knowledge_resources=knowledge_resources,
|
||||
)
|
||||
native_tools: List[Any] = []
|
||||
if sub_state.get("native_connectors"):
|
||||
from kamidso.engine.app.openapi.api_v1.tools.connector_call import (
|
||||
make_connector_call,
|
||||
)
|
||||
|
||||
native_tools.append(make_connector_call(sub_state, user_name=user_name))
|
||||
sub_tool_pack = ToolPack(
|
||||
[sub_tools[name] for name in _subagent_tool_names(report_mode=report_mode)]
|
||||
+ (readonly_connector_tools or [])
|
||||
+ native_tools
|
||||
+ [Terminate()]
|
||||
)
|
||||
|
||||
@@ -500,9 +520,10 @@ an execution mechanism after decomposition, not a decomposition tool.
|
||||
[Usage rules]
|
||||
- Every subtask goal must be self-contained. A sub-agent cannot see the current
|
||||
conversation history, so put all required information in `goal` or `context`.
|
||||
- Sub-agents inherit database, knowledge-base, and read-only MCP tools, but do
|
||||
not know which resource to use by default. Name the required database and
|
||||
relevant tables, knowledge base, or MCP connector in every goal/context.
|
||||
- Sub-agents inherit database, knowledge-base, read-only MCP tools, and, when a
|
||||
native business connector is attached, ``connector_call``. Name the required
|
||||
database, knowledge base, or connector operation in every goal/context.
|
||||
Do not tell a sub-agent to call localhost or invent ``/api/crm`` paths.
|
||||
Sub-agents are read-only; the lead agent must perform write operations.
|
||||
- Dispatch at most N subtasks per call. Excess tasks are truncated and reported;
|
||||
dispatch additional batches when necessary.
|
||||
@@ -510,6 +531,50 @@ an execution mechanism after decomposition, not a decomposition tool.
|
||||
"""
|
||||
|
||||
|
||||
def subagent_thought_event(
|
||||
agent_id: str, agent_name: str, raw_text: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Live thought text for one sub-agent, ready for the chat stream."""
|
||||
display = live_thinking_display(raw_text)
|
||||
if not display:
|
||||
return None
|
||||
return {
|
||||
"type": "agent.thinking",
|
||||
"agent_id": agent_id,
|
||||
"agent_name": agent_name,
|
||||
"content": display,
|
||||
}
|
||||
|
||||
|
||||
def subagent_running_step_event(
|
||||
agent_id: str,
|
||||
agent_name: str,
|
||||
round_num: int,
|
||||
llm_reply: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Announce the tool a sub-agent is about to run, before it returns."""
|
||||
if not llm_reply:
|
||||
return None
|
||||
try:
|
||||
steps = ReActOutputParser().parse(llm_reply)
|
||||
except Exception:
|
||||
return None
|
||||
if not steps or not steps[0].action:
|
||||
return None
|
||||
action = str(steps[0].action)
|
||||
if action.lower() == "terminate":
|
||||
return None
|
||||
return {
|
||||
"type": "agent.step",
|
||||
"agent_id": agent_id,
|
||||
"agent_name": agent_name,
|
||||
"round": round_num,
|
||||
"action": action,
|
||||
"intention": steps[0].action_intention or "",
|
||||
"status": "running",
|
||||
}
|
||||
|
||||
|
||||
def make_dispatch_tool(
|
||||
*,
|
||||
parent_conv_id: str,
|
||||
@@ -522,6 +587,8 @@ def make_dispatch_tool(
|
||||
emit_event: Any,
|
||||
max_parallel: int = 3,
|
||||
report_mode: bool = False,
|
||||
native_connectors: Optional[List[Any]] = None,
|
||||
user_name: Optional[str] = None,
|
||||
):
|
||||
"""Build the ``dispatch_parallel_tasks`` tool bound to the lead context.
|
||||
|
||||
@@ -630,26 +697,50 @@ def make_dispatch_tool(
|
||||
extra_context=extra,
|
||||
batch_id=batch_id,
|
||||
report_mode=report_mode,
|
||||
native_connectors=native_connectors,
|
||||
user_name=user_name,
|
||||
)
|
||||
from kamidso.engine.core.agent import AgentMessage
|
||||
|
||||
# Forward the sub-agent's CONFIRMED tool actions (not raw token
|
||||
# thinking) as agent.step events tagged with this agent_id, so
|
||||
# the frontend can show a live "current action" line and a
|
||||
# drill-down step list. These go through emit_event on their own
|
||||
# channel — they never enter the main loop's round_step_map
|
||||
# (which keys steps by round_num and would collide across
|
||||
# parallel sub-agents).
|
||||
# Live thoughts and tool steps go out on this agent_id channel.
|
||||
# They never enter the lead loop's round_step_map, which would
|
||||
# collide when several sub-agents run at once.
|
||||
pending_sql_by_round: Dict[int, str] = {}
|
||||
raw_thoughts: Dict[str, List[str]] = {}
|
||||
last_thought: Dict[str, str] = {}
|
||||
|
||||
async def _sub_stream_callback(
|
||||
event_type: str, payload: dict, _aid: str = agent_id
|
||||
) -> None:
|
||||
round_num = int(payload.get("round") or 0)
|
||||
if event_type == "thinking_chunk":
|
||||
piece = payload.get("delta_thinking") or payload.get(
|
||||
"delta_text"
|
||||
)
|
||||
if not piece:
|
||||
return
|
||||
parts = raw_thoughts.setdefault(_aid, [])
|
||||
parts.append(str(piece))
|
||||
thought = subagent_thought_event(
|
||||
_aid, title, "".join(parts)
|
||||
)
|
||||
if thought and thought["content"] != last_thought.get(_aid):
|
||||
last_thought[_aid] = thought["content"]
|
||||
await emit_event(thought)
|
||||
return
|
||||
if event_type == "thinking":
|
||||
sql = _extract_sql_from_llm_reply(payload.get("llm_reply"))
|
||||
if sql:
|
||||
pending_sql_by_round[round_num] = sql
|
||||
running = subagent_running_step_event(
|
||||
_aid,
|
||||
title,
|
||||
round_num,
|
||||
str(payload.get("llm_reply") or ""),
|
||||
)
|
||||
if running:
|
||||
running["batch_id"] = batch_id
|
||||
await emit_event(running)
|
||||
return
|
||||
if event_type != "act":
|
||||
return
|
||||
@@ -690,11 +781,13 @@ def make_dispatch_tool(
|
||||
step_event = {
|
||||
"type": "agent.step",
|
||||
"agent_id": _aid,
|
||||
"agent_name": title,
|
||||
"batch_id": batch_id,
|
||||
"action": action,
|
||||
"intention": ao.get("action_intention"),
|
||||
"chunks": chunks,
|
||||
"round": payload.get("round"),
|
||||
"status": "completed",
|
||||
}
|
||||
if action.lower() == "sql_query":
|
||||
sql = _extract_sql_action_input(
|
||||
|
||||
@@ -216,6 +216,8 @@ def update_subagent_history(
|
||||
return
|
||||
|
||||
if event_type == "agent.step":
|
||||
if event.get("status") == "running":
|
||||
return
|
||||
action = _bounded_text(event.get("action"), _MAX_NAME_CHARS)
|
||||
if not action:
|
||||
return
|
||||
|
||||
@@ -353,6 +353,7 @@ def make_react_tools(
|
||||
or bool(react_state.get("database_connectors"))
|
||||
or bool(react_state.get("db_runtime"))
|
||||
),
|
||||
native_connector=bool(react_state.get("native_connectors")),
|
||||
)
|
||||
|
||||
@tool(
|
||||
|
||||
@@ -339,7 +339,11 @@ async def test_dispatch_sql_step_recovers_sql_from_confirmed_thinking_event(
|
||||
)
|
||||
await tool(tasks=[{"goal": "g0", "title": "A"}])
|
||||
|
||||
step = next(e for e in events if e["type"] == "agent.step")
|
||||
step = next(
|
||||
e
|
||||
for e in events
|
||||
if e["type"] == "agent.step" and e.get("status") != "running"
|
||||
)
|
||||
assert step["sql"] == "SELECT * FROM users"
|
||||
assert "action_input" not in step
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ from kamidso.engine.app.openapi.api_v1.subagent.dispatcher import (
|
||||
_filter_readonly_connector_tools,
|
||||
_subagent_tool_names,
|
||||
build_sub_react_agent,
|
||||
subagent_running_step_event,
|
||||
subagent_thought_event,
|
||||
)
|
||||
|
||||
|
||||
@@ -107,6 +109,8 @@ def test_build_sub_prompt_includes_goal():
|
||||
assert "analyze sales" in pt.template
|
||||
# No context block when extra_context is None.
|
||||
assert "Shared context" not in pt.template
|
||||
assert "connector_call" in pt.template
|
||||
assert "localhost" in pt.template
|
||||
|
||||
|
||||
def test_build_sub_prompt_includes_context():
|
||||
@@ -229,3 +233,72 @@ async def test_build_sub_react_agent_batch_id_isolates_conv_id(monkeypatch):
|
||||
assert cid_batch1_sub0 == "p__d1_sub_0"
|
||||
assert cid_batch2_sub0 == "p__d2_sub_0"
|
||||
assert cid_batch1_sub0 != cid_batch2_sub0
|
||||
|
||||
|
||||
def test_subagent_thought_event_keeps_the_live_thought():
|
||||
event = subagent_thought_event(
|
||||
"sub-1",
|
||||
"صفحه ۲",
|
||||
"Thought: اتصال دیدار را با connector_call میخوانم\nAction: connector_call",
|
||||
)
|
||||
assert event is not None
|
||||
assert event["type"] == "agent.thinking"
|
||||
assert "connector_call" in event["content"]
|
||||
assert event["agent_name"] == "صفحه ۲"
|
||||
|
||||
|
||||
def test_subagent_running_step_event_names_the_tool_before_it_returns():
|
||||
reply = (
|
||||
"Thought: صفحه بعد معاملات موفق\n"
|
||||
"Action Intention: دریافت صفحه ۲\n"
|
||||
"Action: connector_call\n"
|
||||
'Action Input: {"name": "search_deals"}\n'
|
||||
)
|
||||
event = subagent_running_step_event("sub-1", "صفحه ۲", 2, reply)
|
||||
assert event is not None
|
||||
assert event["status"] == "running"
|
||||
assert event["action"] == "connector_call"
|
||||
assert event["round"] == 2
|
||||
assert subagent_running_step_event("sub-1", "صفحه ۲", 3, "Action: terminate\nAction Input: {}") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sub_agent_receives_native_connector_call(monkeypatch):
|
||||
import kamidso.engine.core.agent.expand.react_agent as react_mod
|
||||
|
||||
class _FakeBuilt:
|
||||
def __init__(self, tool_pack):
|
||||
self.tool_pack = tool_pack
|
||||
|
||||
class _FakeReActAgent:
|
||||
def __init__(self, *a, **k):
|
||||
self._tool_pack = None
|
||||
|
||||
def bind(self, obj):
|
||||
from kamidso.engine.core.agent.resource import ToolPack
|
||||
|
||||
if isinstance(obj, ToolPack):
|
||||
self._tool_pack = obj
|
||||
return self
|
||||
|
||||
async def build(self):
|
||||
return _FakeBuilt(self._tool_pack)
|
||||
|
||||
monkeypatch.setattr(react_mod, "ReActAgent", _FakeReActAgent)
|
||||
agent, _, state = await build_sub_react_agent(
|
||||
"goal",
|
||||
0,
|
||||
parent_conv_id="parent",
|
||||
llm_client=_make_fake_llm_client(),
|
||||
native_connectors=[
|
||||
{
|
||||
"connector_id": "didar-1",
|
||||
"connector_type": "didar",
|
||||
"display_name": "دیدار",
|
||||
}
|
||||
],
|
||||
user_name="user-1",
|
||||
)
|
||||
tool_names = set(agent.tool_pack._resources.keys())
|
||||
assert "connector_call" in tool_names
|
||||
assert "didar-1" in state["native_connectors"]
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Built-in tools for the ReAct agent in agentic_data_api."""
|
||||
|
||||
from .code_interpreter import make_code_interpreter
|
||||
from .connector_call import make_connector_call
|
||||
from .execute_analysis import make_execute_analysis
|
||||
from .execute_tool import make_execute_tool
|
||||
from .html_interpreter import make_html_interpreter
|
||||
@@ -10,7 +11,9 @@ from .load_file import make_load_file
|
||||
from .load_tools import make_load_tools
|
||||
from .question import make_question
|
||||
from .read_file import make_read_file
|
||||
from .select_skill import make_select_skill # deprecated; not registered on the live agent
|
||||
from .select_skill import (
|
||||
make_select_skill, # deprecated; not registered on the live agent
|
||||
)
|
||||
from .shell_interpreter import make_shell_interpreter
|
||||
from .skill_tools import make_execute_skill_script_file, make_load_skill
|
||||
from .sql_query import make_sql_query
|
||||
@@ -32,5 +35,6 @@ __all__ = [
|
||||
"make_execute_skill_script_file",
|
||||
"make_load_skill",
|
||||
"make_sql_query",
|
||||
"make_connector_call",
|
||||
"make_todowrite",
|
||||
]
|
||||
@@ -119,6 +119,7 @@ def make_code_interpreter(
|
||||
*,
|
||||
sql_enabled: bool = True,
|
||||
database_runtime: bool = False,
|
||||
native_connector: bool = False,
|
||||
):
|
||||
if database_runtime:
|
||||
description = (
|
||||
@@ -169,6 +170,13 @@ def make_code_interpreter(
|
||||
"unless you wrote that file yourself. "
|
||||
'Parameters: {"code": "python code string"}'
|
||||
)
|
||||
if native_connector:
|
||||
description += (
|
||||
" Business-app data is fetched with connector_call, not with Python HTTP clients. "
|
||||
"When connector_call saves a result under PLOT_DIR/connector_results/*.json, "
|
||||
"load that JSON with Python/pandas for calculations, joins, charts or exports. "
|
||||
"Do not use requests/httpx to call attached business apps."
|
||||
)
|
||||
|
||||
@tool(description=description)
|
||||
async def code_interpreter(code: str) -> str:
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Single native connector tool. One call, predefined functions only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from kamidso.engine.core.agent.resource.tool.base import tool
|
||||
from kamidso.native_connectors.errors import (
|
||||
ConnectorAmbiguous,
|
||||
ConnectorNotAttached,
|
||||
NativeConnectorError,
|
||||
)
|
||||
from kamidso.native_connectors.executor import NativeConnectorExecutor
|
||||
from kamidso.native_connectors.redact import redact_text
|
||||
from kamidso.native_connectors.semantic.catalog import find_operation
|
||||
from kamidso.native_connectors.semantic.executor import SemanticExecutor
|
||||
|
||||
_TOOL_DESCRIPTION = """Call one predefined READ function or semantic operation on a native connector attached to this conversation.
|
||||
|
||||
Semantic operations for management questions: sales_summary, deals_created, won_deals, lost_deals, pending_pipeline_snapshot, sales_by_owner, sales_over_time.
|
||||
For those, pass a business body such as {"period": "مهر ۱۴۰۵"}. Do not build Didar Criteria yourself.
|
||||
|
||||
Raw function names must be listed in the attached connector catalog.
|
||||
|
||||
Do not invent API paths, HTTP methods, credentials or function names.
|
||||
|
||||
If the call returns validation_error, correct the body using the returned schema and retry the SAME function.
|
||||
|
||||
Do not switch to another function merely because validation failed.
|
||||
|
||||
The tool validates the request before HTTP, injects credentials securely, calls the external service, and saves structured results for later analysis in code_interpreter.
|
||||
|
||||
Parameters:
|
||||
- name: predefined connector function name
|
||||
- body: JSON body for that function
|
||||
- connection: attached connector name or id when multiple connectors are attached
|
||||
"""
|
||||
|
||||
_REPEATED_INVALID = (
|
||||
"This exact invalid request has failed repeatedly. "
|
||||
"Use the schema from the previous observation and correct the body before retrying."
|
||||
)
|
||||
|
||||
|
||||
def _chunks(content: str, output_type: str = "text") -> str:
|
||||
return json.dumps(
|
||||
{"chunks": [{"output_type": output_type, "content": content}]},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def _canonical_body(body: Any) -> str:
|
||||
if body is None:
|
||||
body = {}
|
||||
return json.dumps(body, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _attached(react_state: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
raw = react_state.get("native_connectors") or {}
|
||||
if isinstance(raw, dict):
|
||||
return [item for item in raw.values() if isinstance(item, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def resolve_connection(
|
||||
instances: List[Dict[str, Any]],
|
||||
connection: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
if not instances:
|
||||
raise ConnectorNotAttached("No native connector is attached to this conversation.")
|
||||
needle = (connection or "").strip()
|
||||
if not needle:
|
||||
if len(instances) == 1:
|
||||
return instances[0]
|
||||
raise ConnectorAmbiguous(
|
||||
[str(item.get("display_name") or item.get("connector_id")) for item in instances]
|
||||
)
|
||||
matches = [
|
||||
item
|
||||
for item in instances
|
||||
if needle == str(item.get("connector_id") or "")
|
||||
or needle == str(item.get("display_name") or "")
|
||||
]
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if not matches:
|
||||
raise ConnectorNotAttached(
|
||||
f"Connector '{needle}' is not attached to this conversation."
|
||||
)
|
||||
raise ConnectorAmbiguous(
|
||||
[str(item.get("display_name") or item.get("connector_id")) for item in matches]
|
||||
)
|
||||
|
||||
|
||||
def _cache_key(connector_id: str, name: str, body: Any) -> str:
|
||||
return connector_id + "\n" + name + "\n" + _canonical_body(body)
|
||||
|
||||
|
||||
def _default_executor() -> NativeConnectorExecutor:
|
||||
system_app = None
|
||||
try:
|
||||
from kamidso.engine.app.openapi.api_v1.agentic_data_api import CFG
|
||||
|
||||
system_app = getattr(CFG, "SYSTEM_APP", None)
|
||||
except Exception:
|
||||
system_app = None
|
||||
from kamidso.engine.core.agent.resource.connector.credential import CredentialStore
|
||||
|
||||
return NativeConnectorExecutor(credential_store=CredentialStore(system_app=system_app))
|
||||
|
||||
|
||||
def make_connector_call(
|
||||
react_state: Dict[str, Any],
|
||||
*,
|
||||
executor: Optional[NativeConnectorExecutor] = None,
|
||||
user_name: Optional[str] = None,
|
||||
):
|
||||
runner = executor or _default_executor()
|
||||
if user_name:
|
||||
react_state["user_name"] = user_name
|
||||
|
||||
@tool("connector_call", description=_TOOL_DESCRIPTION)
|
||||
async def connector_call(
|
||||
name: str,
|
||||
body: Optional[dict] = None,
|
||||
connection: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Call one predefined read function on an attached native connector."""
|
||||
try:
|
||||
instance = resolve_connection(_attached(react_state), connection)
|
||||
except NativeConnectorError as exc:
|
||||
return _chunks(json.dumps(exc.to_payload(), ensure_ascii=False))
|
||||
|
||||
key = _cache_key(str(instance.get("connector_id") or ""), name, body)
|
||||
cache = react_state.setdefault("connector_call_cache", {})
|
||||
if not isinstance(cache, dict):
|
||||
cache = {}
|
||||
react_state["connector_call_cache"] = cache
|
||||
cached = cache.get(key)
|
||||
if isinstance(cached, dict) and cached.get("observation"):
|
||||
cached["hits"] = int(cached.get("hits") or 0) + 1
|
||||
if cached["hits"] >= 2:
|
||||
saved = cached.get("relative_path") or ""
|
||||
where = f" Saved result: PLOT_DIR/{saved}." if saved else ""
|
||||
return _chunks(
|
||||
"This exact connector request was already executed. "
|
||||
"Use the existing result or change the request."
|
||||
+ where
|
||||
)
|
||||
relative = cached.get("relative_path") or ""
|
||||
prefix = "Reuse previous result"
|
||||
if relative:
|
||||
prefix += f":\nPLOT_DIR/{relative}"
|
||||
prefix += "\n\nDo not repeat this exact connector_call.\n\n"
|
||||
return _chunks(prefix + str(cached.get("observation")))
|
||||
|
||||
try:
|
||||
operation = find_operation(str(instance.get("connector_type") or ""), name)
|
||||
if operation is not None:
|
||||
result = await SemanticExecutor(runner).execute(
|
||||
operation=operation,
|
||||
connector_id=str(instance["connector_id"]),
|
||||
body=body,
|
||||
react_state=react_state,
|
||||
display_name=str(instance.get("display_name") or ""),
|
||||
)
|
||||
else:
|
||||
result = await runner.execute(
|
||||
connector_id=str(instance["connector_id"]),
|
||||
function_name=name,
|
||||
body=body if body is not None else {},
|
||||
react_state=react_state,
|
||||
display_name=str(instance.get("display_name") or ""),
|
||||
)
|
||||
except NativeConnectorError as exc:
|
||||
payload = exc.to_payload()
|
||||
text = json.dumps(payload, ensure_ascii=False)
|
||||
if payload.get("http_sent"):
|
||||
cache[key] = {"observation": text, "relative_path": "", "hits": 0}
|
||||
return _chunks(redact_text(text))
|
||||
|
||||
if not result.get("ok"):
|
||||
if result.get("error_type") == "validation_error":
|
||||
failures = react_state.setdefault("connector_failures", {})
|
||||
if not isinstance(failures, dict):
|
||||
failures = {}
|
||||
react_state["connector_failures"] = failures
|
||||
failures[key] = int(failures.get(key) or 0) + 1
|
||||
if failures[key] >= 3:
|
||||
return _chunks(_REPEATED_INVALID)
|
||||
return _chunks(json.dumps(result, ensure_ascii=False))
|
||||
|
||||
observation = redact_text(str(result.get("observation") or ""))
|
||||
cache[key] = {
|
||||
"observation": observation,
|
||||
"relative_path": result.get("relative_path") or "",
|
||||
"hits": 0,
|
||||
}
|
||||
return _chunks(observation, "markdown")
|
||||
|
||||
return connector_call
|
||||
@@ -89,7 +89,7 @@ async def chat_completions(
|
||||
headers = {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Transfer-Encoding": "chunked",
|
||||
}
|
||||
# check chat request
|
||||
|
||||
@@ -242,7 +242,13 @@ class ExcelReader:
|
||||
|
||||
db_exists = os.path.exists(database_name)
|
||||
|
||||
self.db = duckdb.connect(database=database_name, read_only=False)
|
||||
from kamidso.engine.core.datasource.duckdb_memory import (
|
||||
open_tool_duckdb,
|
||||
release_tool_duckdb,
|
||||
)
|
||||
|
||||
self._release_duckdb = release_tool_duckdb
|
||||
self.db = open_tool_duckdb(database_name, read_only=False)
|
||||
|
||||
self.temp_table_name = "temp_table"
|
||||
self.table_name = table_name
|
||||
@@ -269,9 +275,20 @@ class ExcelReader:
|
||||
print(column)
|
||||
|
||||
def close(self):
|
||||
if self.db:
|
||||
self.db.close()
|
||||
self.db = None
|
||||
db = self.db
|
||||
self.db = None
|
||||
if not db:
|
||||
return
|
||||
release = getattr(self, "_release_duckdb", None)
|
||||
if callable(release):
|
||||
try:
|
||||
release(db)
|
||||
except Exception:
|
||||
logger.debug("Failed to release DuckDB memory budget", exc_info=True)
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
logger.debug("Failed to close DuckDB connection", exc_info=True)
|
||||
|
||||
def __del__(self):
|
||||
self.close()
|
||||
|
||||
@@ -37,11 +37,18 @@ class GptsMemory:
|
||||
self._message_memory: GptsMessageMemory = (
|
||||
message_memory if message_memory is not None else DefaultGptsMessageMemory()
|
||||
)
|
||||
self._executor = executor or ThreadPoolExecutor(max_workers=2)
|
||||
self._owns_executor = executor is None
|
||||
self._executor = executor or ThreadPoolExecutor(
|
||||
max_workers=2, thread_name_prefix="gpts-memory"
|
||||
)
|
||||
self.messages_cache: defaultdict = defaultdict(list)
|
||||
self.channels: defaultdict = defaultdict(Queue)
|
||||
self.enable_vis_map: defaultdict = defaultdict(bool)
|
||||
self.start_round_map: defaultdict = defaultdict(int)
|
||||
# DB-GPT chat_messages increments this while it is reading the queue.
|
||||
# Kamidso's ReAct stream does not, so a vis-disabled turn must not
|
||||
# enqueue history snapshots that nobody will consume.
|
||||
self._queue_readers: Dict[str, int] = {}
|
||||
|
||||
@property
|
||||
def plans_memory(self) -> GptsPlansMemory:
|
||||
@@ -75,7 +82,12 @@ class GptsMemory:
|
||||
return self.channels[conv_id] if conv_id in self.channels else None
|
||||
|
||||
def clear(self, conv_id: str):
|
||||
"""Clear gpt memory."""
|
||||
"""Drop one conversation's in-process queue and scratch lists.
|
||||
|
||||
Does not delete MetaDB or any other persisted message history.
|
||||
DB-GPT still calls this when an application chat finishes. Kamidso
|
||||
turns use :meth:`dispose_runtime`, which also stops the owned executor.
|
||||
"""
|
||||
# clear last message queue
|
||||
queue = self.channels.pop(conv_id) # noqa
|
||||
del queue
|
||||
@@ -91,27 +103,133 @@ class GptsMemory:
|
||||
# clear start_roun
|
||||
start_round = self.start_round_map.pop(conv_id) # noqa
|
||||
del start_round
|
||||
self._queue_readers.pop(conv_id, None)
|
||||
|
||||
def attach_queue_reader(self, conv_id: str) -> None:
|
||||
"""Mark a DB-GPT consumer that is blocked on this conversation queue."""
|
||||
self._queue_readers[conv_id] = self._queue_readers.get(conv_id, 0) + 1
|
||||
|
||||
def detach_queue_reader(self, conv_id: str) -> None:
|
||||
"""Release a queue consumer registered by :meth:`attach_queue_reader`."""
|
||||
remaining = self._queue_readers.get(conv_id, 0) - 1
|
||||
if remaining <= 0:
|
||||
self._queue_readers.pop(conv_id, None)
|
||||
else:
|
||||
self._queue_readers[conv_id] = remaining
|
||||
|
||||
def dispose_runtime(self, conv_id: Optional[str] = None) -> None:
|
||||
"""Free temporary scratch state without touching persisted history.
|
||||
|
||||
``message_memory`` and ``plans_memory`` are left intact, including
|
||||
MetaDB rows. When no conversation scratch remains and this object
|
||||
created its own executor, that executor is shut down.
|
||||
"""
|
||||
if conv_id is None:
|
||||
conv_ids = (
|
||||
set(self.channels)
|
||||
| set(self.messages_cache)
|
||||
| set(self.enable_vis_map)
|
||||
| set(self.start_round_map)
|
||||
| set(self._queue_readers)
|
||||
)
|
||||
else:
|
||||
conv_ids = {conv_id}
|
||||
for cid in conv_ids:
|
||||
self._drop_runtime_conversation(cid)
|
||||
if not self._has_runtime_state():
|
||||
self.shutdown_executor()
|
||||
|
||||
def shutdown_executor(self) -> None:
|
||||
"""Stop the executor owned by this memory. Shared executors stay up."""
|
||||
executor = self._executor
|
||||
if executor is None or not self._owns_executor:
|
||||
return
|
||||
self._executor = None
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
def _has_runtime_state(self) -> bool:
|
||||
return bool(
|
||||
self.channels
|
||||
or self.messages_cache
|
||||
or self.enable_vis_map
|
||||
or self.start_round_map
|
||||
or self._queue_readers
|
||||
)
|
||||
|
||||
def _drop_runtime_conversation(self, conv_id: str) -> None:
|
||||
queue = self.channels.pop(conv_id, None)
|
||||
if queue is not None:
|
||||
while True:
|
||||
try:
|
||||
queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
self.messages_cache.pop(conv_id, None)
|
||||
self.enable_vis_map.pop(conv_id, None)
|
||||
self.start_round_map.pop(conv_id, None)
|
||||
self._queue_readers.pop(conv_id, None)
|
||||
|
||||
def _latest_simple_view(self, conv_id: str) -> List[dict]:
|
||||
"""One view of the newest non-human message, for a live queue reader."""
|
||||
messages = self.messages_cache.get(conv_id) or []
|
||||
for message in reversed(messages):
|
||||
if getattr(message, "sender", None) == "Human":
|
||||
continue
|
||||
view_info = getattr(message, "content", None)
|
||||
action_report_str = getattr(message, "action_report", None)
|
||||
if action_report_str:
|
||||
try:
|
||||
action_out = ActionOutput.from_dict(json.loads(action_report_str))
|
||||
except Exception:
|
||||
action_out = None
|
||||
if action_out is not None and action_out.content:
|
||||
view_info = action_out.content
|
||||
return [
|
||||
{
|
||||
"sender": getattr(message, "sender", None),
|
||||
"receiver": getattr(message, "receiver", None),
|
||||
"model": getattr(message, "model_name", None),
|
||||
"markdown": view_info,
|
||||
}
|
||||
]
|
||||
return []
|
||||
|
||||
async def push_message(self, conv_id: str, temp_msg: Optional[str] = None):
|
||||
"""Push conversation message."""
|
||||
queue = self.queue(conv_id)
|
||||
enable_vis_tag = self.enable_vis_message(conv_id=conv_id)
|
||||
if enable_vis_tag:
|
||||
# 如果有临时消息内容需要push 拼接再最末尾,否则直接从短期记忆中发布最后消息
|
||||
message_view = await self.app_link_chat_message(conv_id)
|
||||
if temp_msg:
|
||||
temp_view = await self.agent_stream_message(temp_msg)
|
||||
message_view = message_view + "\n" + temp_view
|
||||
await queue.put(message_view)
|
||||
"""Push conversation message.
|
||||
|
||||
else:
|
||||
# 非VIS消息模式,直接推送简单消息列表即可,不做任何处理
|
||||
message_views = await self.simple_message(conv_id)
|
||||
VIS mode still publishes the DB-GPT view the legacy consumer renders.
|
||||
With VIS disabled, the full history snapshot is not built. A registered
|
||||
queue reader receives only the latest message (``chunk[-1]``). Kamidso
|
||||
does not register a reader, so nothing is queued.
|
||||
"""
|
||||
enable_vis_tag = self.enable_vis_message(conv_id=conv_id)
|
||||
if not enable_vis_tag:
|
||||
if self._queue_readers.get(conv_id, 0) <= 0:
|
||||
return
|
||||
message_views = self._latest_simple_view(conv_id)
|
||||
if temp_msg:
|
||||
temp_view = await self.agent_stream_message(temp_msg, False)
|
||||
if temp_view and len(temp_view) > 0:
|
||||
if temp_view:
|
||||
message_views.extend(temp_view)
|
||||
if not message_views:
|
||||
return
|
||||
queue = self.queue(conv_id)
|
||||
if queue is None:
|
||||
return
|
||||
await queue.put(message_views)
|
||||
return
|
||||
|
||||
queue = self.queue(conv_id)
|
||||
if queue is None:
|
||||
return
|
||||
# 如果有临时消息内容需要push 拼接再最末尾,否则直接从短期记忆中发布最后消息
|
||||
message_view = await self.app_link_chat_message(conv_id)
|
||||
if temp_msg:
|
||||
temp_view = await self.agent_stream_message(temp_msg)
|
||||
message_view = message_view + "\n" + temp_view
|
||||
await queue.put(message_view)
|
||||
|
||||
async def complete(self, conv_id: str):
|
||||
"""Complete conversation message."""
|
||||
@@ -522,14 +640,18 @@ class GptsMemory:
|
||||
conv_id: str,
|
||||
):
|
||||
"""Get chat messages."""
|
||||
while True:
|
||||
queue = self.queue(conv_id)
|
||||
if not queue:
|
||||
break
|
||||
item = await queue.get()
|
||||
if item == "[DONE]":
|
||||
queue.task_done()
|
||||
break
|
||||
else:
|
||||
yield item
|
||||
await asyncio.sleep(0.005)
|
||||
self.attach_queue_reader(conv_id)
|
||||
try:
|
||||
while True:
|
||||
queue = self.queue(conv_id)
|
||||
if not queue:
|
||||
break
|
||||
item = await queue.get()
|
||||
if item == "[DONE]":
|
||||
queue.task_done()
|
||||
break
|
||||
else:
|
||||
yield item
|
||||
await asyncio.sleep(0.005)
|
||||
finally:
|
||||
self.detach_queue_reader(conv_id)
|
||||
@@ -14,16 +14,25 @@ _SENSITIVE_KEYS = frozenset({"password", "token", "secret", "api_key", "github_t
|
||||
class CredentialStore:
|
||||
def __init__(self, system_app: Optional[Any] = None) -> None:
|
||||
self._system_app = system_app
|
||||
self._durable = False
|
||||
self._master_key = self._resolve_master_key()
|
||||
self._fernet = self._build_fernet(self._master_key)
|
||||
|
||||
def has_durable_key(self) -> bool:
|
||||
"""True when credentials can be decrypted after a process restart."""
|
||||
return self._durable
|
||||
|
||||
def _resolve_master_key(self) -> bytes:
|
||||
config_key: Optional[str] = None
|
||||
if self._system_app is not None and getattr(self._system_app, "config", None):
|
||||
config_key = self._system_app.config.get("kamidso.engine.core.app.global.encrypt_key")
|
||||
raw_key = config_key or os.environ.get("ENCRYPT_KEY")
|
||||
if isinstance(raw_key, str):
|
||||
raw_key = raw_key.strip()
|
||||
if raw_key:
|
||||
self._durable = True
|
||||
return raw_key.encode()
|
||||
self._durable = False
|
||||
logger.warning(
|
||||
"CredentialStore falling back to ephemeral master key because no "
|
||||
"kamidso.engine.core.app.global.encrypt_key or ENCRYPT_KEY was provided. "
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Cap DuckDB memory for tool connections.
|
||||
|
||||
DuckDB's default limit is about 80% of host RAM per database. Each tool
|
||||
connection is capped, and open tool connections split one aggregate budget
|
||||
so several analyses cannot each take the full cap at once.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import weakref
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_LIMIT = "2GB"
|
||||
_MEMORY_LIMIT_ENV = "KAMIDSO_DUCKDB_MEMORY_LIMIT"
|
||||
_AGGREGATE_LIMIT_ENV = "KAMIDSO_DUCKDB_AGGREGATE_MEMORY_LIMIT"
|
||||
|
||||
_LOCK = threading.Lock()
|
||||
_LIVE: Dict[int, weakref.ReferenceType] = {}
|
||||
_DEFAULT_CAPPED = False
|
||||
|
||||
_UNITS = {
|
||||
"tib": 1024**4,
|
||||
"gib": 1024**3,
|
||||
"mib": 1024**2,
|
||||
"kib": 1024,
|
||||
"tb": 1000**4,
|
||||
"gb": 1000**3,
|
||||
"mb": 1000**2,
|
||||
"kb": 1000,
|
||||
"ti": 1024**4,
|
||||
"gi": 1024**3,
|
||||
"mi": 1024**2,
|
||||
"ki": 1024,
|
||||
"t": 1000**4,
|
||||
"g": 1000**3,
|
||||
"m": 1000**2,
|
||||
"k": 1000,
|
||||
"b": 1,
|
||||
}
|
||||
|
||||
|
||||
def configured_memory_limit() -> str:
|
||||
"""Per-connection cap. ``KAMIDSO_DUCKDB_MEMORY_LIMIT`` overrides the 2GB default."""
|
||||
raw = (os.environ.get(_MEMORY_LIMIT_ENV) or "").strip()
|
||||
if not raw:
|
||||
return _DEFAULT_LIMIT
|
||||
try:
|
||||
parse_memory_limit(raw)
|
||||
except ValueError:
|
||||
logger.warning("Ignoring invalid %s=%s; using %s", _MEMORY_LIMIT_ENV, raw, _DEFAULT_LIMIT)
|
||||
return _DEFAULT_LIMIT
|
||||
return raw
|
||||
|
||||
|
||||
def configured_aggregate_limit() -> str:
|
||||
"""Budget shared by open tool connections. Defaults to the per-connection cap."""
|
||||
raw = (os.environ.get(_AGGREGATE_LIMIT_ENV) or "").strip()
|
||||
if not raw:
|
||||
return configured_memory_limit()
|
||||
try:
|
||||
parse_memory_limit(raw)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Ignoring invalid %s=%s; using the per-connection limit",
|
||||
_AGGREGATE_LIMIT_ENV,
|
||||
raw,
|
||||
)
|
||||
return configured_memory_limit()
|
||||
return raw
|
||||
|
||||
|
||||
def parse_memory_limit(value: str) -> int:
|
||||
"""Parse ``2GB``, ``512MiB``, or a bare byte count."""
|
||||
text = value.strip().lower().replace(" ", "")
|
||||
if not text:
|
||||
raise ValueError("empty memory limit")
|
||||
if text.isdigit():
|
||||
return int(text)
|
||||
for unit in sorted(_UNITS, key=len, reverse=True):
|
||||
if text.endswith(unit):
|
||||
number = text[: -len(unit)]
|
||||
if not number:
|
||||
raise ValueError(value)
|
||||
return int(float(number) * _UNITS[unit])
|
||||
raise ValueError(value)
|
||||
|
||||
|
||||
def format_duckdb_limit(num_bytes: int) -> str:
|
||||
"""Render a byte count with a unit DuckDB's parser accepts."""
|
||||
if num_bytes <= 0:
|
||||
return "1MiB"
|
||||
if num_bytes % (1024**3) == 0:
|
||||
return f"{num_bytes // (1024**3)}GiB"
|
||||
mebibytes = max(1, num_bytes // (1024**2))
|
||||
return f"{mebibytes}MiB"
|
||||
|
||||
|
||||
def cap_duckdb_default_connection() -> None:
|
||||
"""Stop the module-level DuckDB connection from taking ~80% of host RAM.
|
||||
|
||||
That connection is not part of the tool aggregate. It only needs a ceiling.
|
||||
"""
|
||||
global _DEFAULT_CAPPED
|
||||
if _DEFAULT_CAPPED:
|
||||
return
|
||||
_DEFAULT_CAPPED = True
|
||||
try:
|
||||
import duckdb
|
||||
except Exception:
|
||||
logger.debug("duckdb is not installed; skip default memory cap", exc_info=True)
|
||||
return
|
||||
try:
|
||||
connection = duckdb.default_connection()
|
||||
connection.execute(f"SET memory_limit='{configured_memory_limit()}'")
|
||||
except Exception:
|
||||
logger.warning("Failed to cap the default DuckDB connection", exc_info=True)
|
||||
|
||||
|
||||
def register_tool_duckdb(connection: object) -> None:
|
||||
"""Apply the shared budget to a tool connection and remember it."""
|
||||
cap_duckdb_default_connection()
|
||||
key = id(connection)
|
||||
|
||||
def _dead(ref: weakref.ReferenceType, conn_id: int = key) -> None:
|
||||
with _LOCK:
|
||||
current = _LIVE.get(conn_id)
|
||||
if current is ref:
|
||||
_LIVE.pop(conn_id, None)
|
||||
_rebalance_locked()
|
||||
|
||||
with _LOCK:
|
||||
_LIVE[key] = weakref.ref(connection, _dead)
|
||||
_rebalance_locked()
|
||||
|
||||
|
||||
def release_tool_duckdb(connection: object) -> None:
|
||||
"""Forget a tool connection and give its budget back to the ones still open."""
|
||||
key = id(connection)
|
||||
with _LOCK:
|
||||
_LIVE.pop(key, None)
|
||||
_rebalance_locked()
|
||||
|
||||
|
||||
def bind_duckdb_memory_limit(engine: object) -> None:
|
||||
"""Set the cap on every DBAPI connection this SQLAlchemy engine opens."""
|
||||
if getattr(engine, "_kamidso_duckdb_memory_bound", False):
|
||||
return
|
||||
setattr(engine, "_kamidso_duckdb_memory_bound", True)
|
||||
cap_duckdb_default_connection()
|
||||
from sqlalchemy import event
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _on_connect(dbapi_connection, _connection_record) -> None: # noqa: ANN001
|
||||
register_tool_duckdb(dbapi_connection)
|
||||
|
||||
@event.listens_for(engine, "close")
|
||||
def _on_close(dbapi_connection, _connection_record) -> None: # noqa: ANN001
|
||||
release_tool_duckdb(dbapi_connection)
|
||||
|
||||
|
||||
def open_tool_duckdb(database: str = ":memory:", read_only: bool = False):
|
||||
"""Open a DuckDB database for a tool and register it under the memory budget."""
|
||||
import duckdb
|
||||
|
||||
connection = duckdb.connect(database=database, read_only=read_only)
|
||||
register_tool_duckdb(connection)
|
||||
return connection
|
||||
|
||||
|
||||
def reset_duckdb_memory_state_for_tests() -> None:
|
||||
"""Drop registry entries. Does not close caller-owned connections."""
|
||||
global _DEFAULT_CAPPED
|
||||
with _LOCK:
|
||||
_LIVE.clear()
|
||||
_DEFAULT_CAPPED = False
|
||||
|
||||
|
||||
def _share_bytes(open_connections: int) -> int:
|
||||
count = max(1, open_connections)
|
||||
per_connection = parse_memory_limit(configured_memory_limit())
|
||||
aggregate = parse_memory_limit(configured_aggregate_limit())
|
||||
return max(1024 * 1024, min(per_connection, aggregate // count))
|
||||
|
||||
|
||||
def _limit_sql(open_connections: int) -> str:
|
||||
count = max(1, open_connections)
|
||||
per_text = configured_memory_limit()
|
||||
per_bytes = parse_memory_limit(per_text)
|
||||
share = _share_bytes(count)
|
||||
if count == 1 and share == per_bytes:
|
||||
return per_text
|
||||
return format_duckdb_limit(share)
|
||||
|
||||
|
||||
def _rebalance_locked() -> None:
|
||||
live = _live_connections_locked()
|
||||
if not live:
|
||||
return
|
||||
statement = f"SET memory_limit='{_limit_sql(len(live))}'"
|
||||
for connection in live:
|
||||
try:
|
||||
connection.execute(statement)
|
||||
except Exception:
|
||||
logger.debug("Could not update DuckDB memory_limit", exc_info=True)
|
||||
|
||||
|
||||
def _live_connections_locked() -> List[object]:
|
||||
dead: List[int] = []
|
||||
live: List[object] = []
|
||||
for key, ref in _LIVE.items():
|
||||
connection: Optional[object] = ref()
|
||||
if connection is None:
|
||||
dead.append(key)
|
||||
else:
|
||||
live.append(connection)
|
||||
for key in dead:
|
||||
_LIVE.pop(key, None)
|
||||
return live
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from concurrent.futures import Executor
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, List, Optional, Type, Union
|
||||
@@ -347,6 +348,9 @@ class OpenAILLMClient(ProxyLLMClient):
|
||||
self._stream_idle_timeout = stream_idle_timeout_seconds()
|
||||
self._api_type = api_type
|
||||
self._client = openai_client
|
||||
# Tests inject one client. Production streams each open their own so a
|
||||
# proxy reset on one chat cannot close the other chat's socket.
|
||||
self._client_is_injected = openai_client is not None
|
||||
self._openai_kwargs = openai_kwargs or {}
|
||||
resolved_reasoning = coerce_optional_bool(reasoning_enabled)
|
||||
if resolved_reasoning is None:
|
||||
@@ -416,6 +420,33 @@ class OpenAILLMClient(ProxyLLMClient):
|
||||
)
|
||||
return self._client
|
||||
|
||||
@asynccontextmanager
|
||||
async def _open_stream_client(self):
|
||||
"""HTTP client that lives only for this stream.
|
||||
|
||||
A shared client keeps one proxy tunnel. The next user's stream is
|
||||
written onto that tunnel and both responses reset.
|
||||
"""
|
||||
if self._client_is_injected:
|
||||
yield self._client
|
||||
return
|
||||
from kamidso.engine.core.model.utils.chatgpt_utils import _build_openai_client
|
||||
|
||||
_api_type, client = _build_openai_client(
|
||||
init_params=self._init_params,
|
||||
timeout=self._stream_idle_timeout,
|
||||
isolated=True,
|
||||
)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
close = getattr(client, "close", None)
|
||||
if close is not None:
|
||||
try:
|
||||
await close()
|
||||
except Exception:
|
||||
logger.debug("Failed to close stream LLM client", exc_info=True)
|
||||
|
||||
@property
|
||||
def default_model(self) -> str:
|
||||
model = self._model
|
||||
@@ -540,9 +571,15 @@ class OpenAILLMClient(ProxyLLMClient):
|
||||
async for r in self.generate_stream_v1(messages, payload):
|
||||
yield r
|
||||
|
||||
async def _create_chat(self, messages: List[Dict[str, Any]], payload: Dict[str, Any]):
|
||||
async def _create_chat(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
payload: Dict[str, Any],
|
||||
client: Optional["ClientType"] = None,
|
||||
):
|
||||
openai_client = client if client is not None else self.client
|
||||
return await asyncio.wait_for(
|
||||
self.client.chat.completions.create(messages=messages, **payload),
|
||||
openai_client.chat.completions.create(messages=messages, **payload),
|
||||
timeout=self._stream_idle_timeout,
|
||||
)
|
||||
|
||||
@@ -600,49 +637,52 @@ class OpenAILLMClient(ProxyLLMClient):
|
||||
for attempt in range(1, attempts + 1):
|
||||
yielded = False
|
||||
try:
|
||||
chat_completion = await self._create_chat(messages, payload)
|
||||
text = ""
|
||||
reasoning_content = ""
|
||||
usage = None
|
||||
tool_calls_acc: Optional[List[Dict[str, Any]]] = None
|
||||
async for r in _iter_with_idle_timeout(
|
||||
chat_completion, self._stream_idle_timeout
|
||||
):
|
||||
if len(r.choices) == 0:
|
||||
continue
|
||||
# Check for empty 'choices' issue in Azure GPT-4o responses
|
||||
if r.choices[0] is not None and r.choices[0].delta is None:
|
||||
continue
|
||||
delta_obj = r.choices[0].delta
|
||||
if hasattr(delta_obj, "reasoning_content"):
|
||||
reasoning_content += delta_obj.reasoning_content or ""
|
||||
if r.choices[0].delta.content is not None:
|
||||
text += r.choices[0].delta.content
|
||||
delta_tool_calls = getattr(delta_obj, "tool_calls", None) or None
|
||||
if delta_tool_calls:
|
||||
tool_calls_acc = merge_streaming_tool_calls(
|
||||
tool_calls_acc, delta_tool_calls
|
||||
)
|
||||
if text or reasoning_content:
|
||||
if hasattr(r, "usage") and r.usage is not None:
|
||||
usage = r.usage.dict()
|
||||
async with self._open_stream_client() as client:
|
||||
chat_completion = await self._create_chat(
|
||||
messages, payload, client=client
|
||||
)
|
||||
text = ""
|
||||
reasoning_content = ""
|
||||
usage = None
|
||||
tool_calls_acc: Optional[List[Dict[str, Any]]] = None
|
||||
async for r in _iter_with_idle_timeout(
|
||||
chat_completion, self._stream_idle_timeout
|
||||
):
|
||||
if len(r.choices) == 0:
|
||||
continue
|
||||
# Check for empty 'choices' issue in Azure GPT-4o responses
|
||||
if r.choices[0] is not None and r.choices[0].delta is None:
|
||||
continue
|
||||
delta_obj = r.choices[0].delta
|
||||
if hasattr(delta_obj, "reasoning_content"):
|
||||
reasoning_content += delta_obj.reasoning_content or ""
|
||||
if r.choices[0].delta.content is not None:
|
||||
text += r.choices[0].delta.content
|
||||
delta_tool_calls = getattr(delta_obj, "tool_calls", None) or None
|
||||
if delta_tool_calls:
|
||||
tool_calls_acc = merge_streaming_tool_calls(
|
||||
tool_calls_acc, delta_tool_calls
|
||||
)
|
||||
if text or reasoning_content:
|
||||
if hasattr(r, "usage") and r.usage is not None:
|
||||
usage = r.usage.dict()
|
||||
yielded = True
|
||||
yield self._drop_thinking_if_disabled(
|
||||
ModelOutput.build(text, reasoning_content, usage=usage),
|
||||
payload,
|
||||
)
|
||||
if tool_calls_acc:
|
||||
yielded = True
|
||||
yield self._drop_thinking_if_disabled(
|
||||
ModelOutput.build(text, reasoning_content, usage=usage),
|
||||
ModelOutput.build(
|
||||
text,
|
||||
reasoning_content,
|
||||
usage=usage,
|
||||
tool_calls=tool_calls_acc,
|
||||
),
|
||||
payload,
|
||||
)
|
||||
if tool_calls_acc:
|
||||
yielded = True
|
||||
yield self._drop_thinking_if_disabled(
|
||||
ModelOutput.build(
|
||||
text,
|
||||
reasoning_content,
|
||||
usage=usage,
|
||||
tool_calls=tool_calls_acc,
|
||||
),
|
||||
payload,
|
||||
)
|
||||
return
|
||||
return
|
||||
except Exception as exc:
|
||||
if not _is_stream_stall(exc) or yielded or attempt >= attempts:
|
||||
if _is_stream_stall(exc):
|
||||
|
||||
@@ -96,3 +96,50 @@ async def test_stalled_stream_retries_the_same_request(monkeypatch):
|
||||
|
||||
assert texts == ["بازیابی شد"]
|
||||
assert completions_client.completions.calls == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_streams_open_separate_http_clients(monkeypatch):
|
||||
created = []
|
||||
|
||||
class _OneShot:
|
||||
default_headers = {}
|
||||
|
||||
def __init__(self, isolated: bool):
|
||||
self.isolated = isolated
|
||||
self.closed = False
|
||||
self.chat = SimpleNamespace(completions=_Completions([_TokenStream("ok")]))
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
def _fake_build(init_params, timeout=None, isolated=False):
|
||||
client = _OneShot(isolated)
|
||||
created.append(client)
|
||||
return "open_ai", client
|
||||
|
||||
monkeypatch.setattr(
|
||||
"kamidso.engine.core.model.utils.chatgpt_utils._build_openai_client",
|
||||
_fake_build,
|
||||
)
|
||||
owner = OpenAILLMClient(api_key="test-key", model="qwen")
|
||||
|
||||
async def collect():
|
||||
texts = []
|
||||
async for output in owner.generate_stream_v1(
|
||||
[], {"model": "qwen", "stream": True}
|
||||
):
|
||||
texts.append(output.text)
|
||||
return texts
|
||||
|
||||
first, second = await asyncio.gather(collect(), collect())
|
||||
|
||||
isolated = [client for client in created if client.isolated]
|
||||
shared = [client for client in created if not client.isolated]
|
||||
assert first == ["ok"]
|
||||
assert second == ["ok"]
|
||||
assert len(shared) == 1
|
||||
assert len(isolated) == 2
|
||||
assert isolated[0] is not isolated[1]
|
||||
assert all(client.closed for client in isolated)
|
||||
assert shared[0].closed is False
|
||||
@@ -113,6 +113,8 @@ def _initialize_openai_v1(init_params: OpenAIParameters):
|
||||
def _build_openai_client(
|
||||
init_params: OpenAIParameters,
|
||||
timeout: Optional[float] = None,
|
||||
*,
|
||||
isolated: bool = False,
|
||||
) -> Tuple[str, ClientType]:
|
||||
import httpx
|
||||
|
||||
@@ -146,13 +148,22 @@ def _build_openai_client(
|
||||
|
||||
# Remove proxies for httpx AsyncClient when httpx version >= 0.28.0
|
||||
httpx_version = metadata.version("httpx")
|
||||
limits = openai_httpx_limits()
|
||||
client_kwargs = {"limits": limits}
|
||||
# A stream-scoped client must not park its socket in a shared pool.
|
||||
# Proxies reuse that socket for the next user and both streams reset.
|
||||
limits = (
|
||||
httpx.Limits(max_connections=4, max_keepalive_connections=0)
|
||||
if isolated
|
||||
else openai_httpx_limits()
|
||||
)
|
||||
client_kwargs = {"limits": limits, "http2": False}
|
||||
if http_timeout is not None:
|
||||
client_kwargs["timeout"] = http_timeout
|
||||
proxy = init_params.proxy
|
||||
if isinstance(proxy, str):
|
||||
proxy = proxy.strip() or None
|
||||
if httpx_version >= "0.28.0":
|
||||
if init_params.proxy:
|
||||
client_kwargs["proxy"] = init_params.proxy
|
||||
if proxy:
|
||||
client_kwargs["proxy"] = proxy
|
||||
http_client = httpx.AsyncClient(**client_kwargs)
|
||||
elif init_params.proxies:
|
||||
client_kwargs["proxies"] = init_params.proxies
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"""httpx connection limits for the OpenAI-compatible proxy client."""
|
||||
|
||||
from kamidso.engine.core.model.utils.chatgpt_utils import openai_httpx_limits
|
||||
import pytest
|
||||
|
||||
from kamidso.engine.core.model.utils.chatgpt_utils import (
|
||||
OpenAIParameters,
|
||||
_build_openai_client,
|
||||
openai_httpx_limits,
|
||||
)
|
||||
|
||||
|
||||
def test_openai_httpx_limits_match_proxy_concurrency():
|
||||
@@ -8,3 +14,18 @@ def test_openai_httpx_limits_match_proxy_concurrency():
|
||||
|
||||
assert limits.max_connections == 100
|
||||
assert limits.max_keepalive_connections == 40
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_isolated_stream_client_does_not_reuse_connections():
|
||||
_api_type, client = _build_openai_client(
|
||||
OpenAIParameters(api_key="test-key", api_base="https://example.invalid/v1"),
|
||||
timeout=30,
|
||||
isolated=True,
|
||||
)
|
||||
try:
|
||||
pool = client._client._transport._pool
|
||||
assert pool._max_keepalive_connections == 0
|
||||
assert pool._max_connections == 4
|
||||
finally:
|
||||
await client.close()
|
||||
@@ -49,6 +49,12 @@ class DuckDbConnector(RDBMSConnector):
|
||||
db_type: str = "duckdb"
|
||||
db_dialect: str = "duckdb"
|
||||
|
||||
def __init__(self, engine, **kwargs: Any):
|
||||
from kamidso.engine.core.datasource.duckdb_memory import bind_duckdb_memory_limit
|
||||
|
||||
bind_duckdb_memory_limit(engine)
|
||||
super().__init__(engine, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def param_class(cls) -> Type[DuckDbConnectorParameters]:
|
||||
"""Return the parameter class."""
|
||||
|
||||
@@ -319,6 +319,9 @@ class MultiAgents(BaseComponent, ABC):
|
||||
# init agent memory
|
||||
agent_memory = self.get_or_build_agent_memory(conv_id, gpts_name)
|
||||
|
||||
# Register before the agent task starts so early messages still have
|
||||
# a consumer when VIS is disabled and the simple stream reads chunk[-1].
|
||||
self.memory.attach_queue_reader(agent_conv_id)
|
||||
task = None
|
||||
try:
|
||||
task = asyncio.create_task(
|
||||
@@ -420,6 +423,7 @@ class MultiAgents(BaseComponent, ABC):
|
||||
yield task, str(e), agent_conv_id
|
||||
|
||||
finally:
|
||||
self.memory.detach_queue_reader(agent_conv_id)
|
||||
self.memory.clear(agent_conv_id)
|
||||
|
||||
def is_flow_chat(self, gpts_name: str):
|
||||
@@ -735,21 +739,25 @@ class MultiAgents(BaseComponent, ABC):
|
||||
user_code: str = None,
|
||||
system_app: str = None,
|
||||
):
|
||||
while True:
|
||||
queue = self.memory.queue(conv_id)
|
||||
if not queue:
|
||||
break
|
||||
item = await queue.get()
|
||||
if item == "[DONE]":
|
||||
queue.task_done()
|
||||
break
|
||||
elif isinstance(item, str) and item.startswith("__CONTEXT_STATUS__"):
|
||||
# Forward context status as a dedicated SSE event
|
||||
yield item
|
||||
await asyncio.sleep(0.005)
|
||||
else:
|
||||
yield item
|
||||
await asyncio.sleep(0.005)
|
||||
self.memory.attach_queue_reader(conv_id)
|
||||
try:
|
||||
while True:
|
||||
queue = self.memory.queue(conv_id)
|
||||
if not queue:
|
||||
break
|
||||
item = await queue.get()
|
||||
if item == "[DONE]":
|
||||
queue.task_done()
|
||||
break
|
||||
elif isinstance(item, str) and item.startswith("__CONTEXT_STATUS__"):
|
||||
# Forward context status as a dedicated SSE event
|
||||
yield item
|
||||
await asyncio.sleep(0.005)
|
||||
else:
|
||||
yield item
|
||||
await asyncio.sleep(0.005)
|
||||
finally:
|
||||
self.memory.detach_queue_reader(conv_id)
|
||||
|
||||
async def stable_message(
|
||||
self, conv_id: str, user_code: str = None, system_app: str = None
|
||||
|
||||
@@ -124,6 +124,33 @@ async def list_connector_types() -> Result[List[Dict[str, Any]]]:
|
||||
)
|
||||
)
|
||||
options.append(_CUSTOM_MCP_OPTION)
|
||||
from kamidso.native_connectors.flags import (
|
||||
didar_connector_enabled,
|
||||
native_connectors_v1_enabled,
|
||||
)
|
||||
from kamidso.native_connectors.registry import get_registry
|
||||
|
||||
if native_connectors_v1_enabled() and didar_connector_enabled():
|
||||
registry = get_registry()
|
||||
if registry.contains("didar"):
|
||||
provider = registry.get("didar")
|
||||
options.append(
|
||||
ConnectorTypeOption(
|
||||
type=provider.id,
|
||||
display_name=provider.display_name,
|
||||
description="Read-only Didar CRM connector",
|
||||
category=provider.category or "crm",
|
||||
is_custom=False,
|
||||
auth_fields=[
|
||||
{
|
||||
"name": provider.credential_field,
|
||||
"label": "API Key",
|
||||
"type": "password",
|
||||
"required": True,
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
return Result.succ([o.model_dump() for o in options])
|
||||
|
||||
|
||||
@@ -265,6 +292,22 @@ async def test_connector(
|
||||
return Result.failed(msg=str(e))
|
||||
|
||||
|
||||
@router.get("/{connector_id}/functions")
|
||||
async def get_connector_functions(
|
||||
connector_id: str,
|
||||
service: ConnectorService = Depends(get_service),
|
||||
) -> Result[Dict[str, Any]]:
|
||||
try:
|
||||
return Result.succ(service.list_native_functions(connector_id))
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
except Exception as exc:
|
||||
logger.exception("Get connector functions failed")
|
||||
return Result.failed(msg=str(exc))
|
||||
|
||||
|
||||
@router.get("/{connector_id}/tools", response_model=Result[ConnectorToolsResponse])
|
||||
async def get_connector_tools(
|
||||
connector_id: str,
|
||||
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
@@ -24,6 +25,25 @@ from ..models.models import ConnectorInstanceDao, ConnectorInstanceEntity
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utc_stamp() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
class ConnectorProbeFailed(ValueError):
|
||||
"""Health check failed before a native connector credential was saved."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
status_code: Optional[int] = None,
|
||||
error_type: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.error_type = error_type
|
||||
|
||||
|
||||
class ConnectorCreateRequest(BaseModel):
|
||||
"""Request model for creating a connector."""
|
||||
|
||||
@@ -158,6 +178,109 @@ class ConnectorService(
|
||||
"""Initialize the service."""
|
||||
self._system_app = system_app
|
||||
self._credential_store = CredentialStore(system_app=system_app)
|
||||
from kamidso.native_connectors.flags import native_connectors_v1_enabled
|
||||
from kamidso.native_connectors.registry import get_registry
|
||||
|
||||
if native_connectors_v1_enabled():
|
||||
get_registry()
|
||||
|
||||
def _is_native_type(self, connector_type: Optional[str]) -> bool:
|
||||
from kamidso.native_connectors.flags import native_connectors_v1_enabled
|
||||
from kamidso.native_connectors.registry import get_registry
|
||||
|
||||
if not native_connectors_v1_enabled() or not connector_type:
|
||||
return False
|
||||
return get_registry().contains(connector_type)
|
||||
|
||||
def _require_durable_native_key(self) -> None:
|
||||
from kamidso.native_connectors.flags import is_production
|
||||
|
||||
if is_production() and not self._credential_store.has_durable_key():
|
||||
raise ValueError(
|
||||
"ENCRYPT_KEY must be configured before creating a native connector "
|
||||
"in production."
|
||||
)
|
||||
|
||||
def _native_executor(self):
|
||||
from kamidso.native_connectors.executor import NativeConnectorExecutor
|
||||
|
||||
return NativeConnectorExecutor(credential_store=self._credential_store)
|
||||
|
||||
def _create_native_connector(self, request: ConnectorCreateRequest) -> ConnectorResponse:
|
||||
"""Persist a native connector only after its catalog health check succeeds."""
|
||||
self._require_durable_native_key()
|
||||
from kamidso.native_connectors.registry import get_registry
|
||||
|
||||
provider = get_registry().get(request.connector_type)
|
||||
credentials = {k: str(v) for k, v in request.credentials.items()}
|
||||
if not str(credentials.get(provider.credential_field) or "").strip():
|
||||
raise ValueError(
|
||||
f"Missing credential field: {provider.credential_field}"
|
||||
)
|
||||
loop = get_or_create_event_loop()
|
||||
probed = loop.run_until_complete(
|
||||
self._native_executor().probe(request.connector_type, credentials)
|
||||
)
|
||||
if not probed.get("success"):
|
||||
status_code = probed.get("status_code")
|
||||
raise ConnectorProbeFailed(
|
||||
str(probed.get("message") or "Connection test failed"),
|
||||
status_code=status_code if isinstance(status_code, int) else None,
|
||||
error_type=str(probed.get("error_type") or "") or None,
|
||||
)
|
||||
|
||||
connector_id = str(uuid.uuid4())
|
||||
encryption_salt = self._credential_store.generate_salt()
|
||||
encrypted_credentials = self._credential_store.encrypt(
|
||||
credentials,
|
||||
encryption_salt,
|
||||
)
|
||||
config = dict(request.config or {})
|
||||
config["last_tested_at"] = _utc_stamp()
|
||||
config["last_test_success"] = True
|
||||
config_json = json.dumps(config)
|
||||
entity = ConnectorInstanceEntity(
|
||||
connector_id=connector_id,
|
||||
connector_type=request.connector_type,
|
||||
display_name=request.display_name,
|
||||
encrypted_credentials=encrypted_credentials,
|
||||
encryption_salt=encryption_salt,
|
||||
status="active",
|
||||
config_json=config_json,
|
||||
user_name=request.user_name,
|
||||
sys_code=request.sys_code,
|
||||
)
|
||||
with self._dao.session() as session:
|
||||
session.add(entity)
|
||||
session.flush()
|
||||
session.refresh(entity)
|
||||
return _entity_to_response(entity)
|
||||
|
||||
def list_native_functions(self, connector_id: str) -> Dict[str, Any]:
|
||||
"""Return the predefined function catalog summary for one native connector."""
|
||||
connector = self.get_connector(connector_id)
|
||||
if connector is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Connector '{connector_id}' not found"
|
||||
)
|
||||
if not self._is_native_type(connector.connector_type):
|
||||
raise ValueError(f"Connector '{connector_id}' is not a native connector")
|
||||
from kamidso.native_connectors.registry import get_registry
|
||||
|
||||
provider = get_registry().get(connector.connector_type)
|
||||
return {
|
||||
"provider": provider.id,
|
||||
"functions": [
|
||||
{
|
||||
"name": function.name,
|
||||
"description": function.description,
|
||||
"domain": function.domain,
|
||||
"effect": function.effect,
|
||||
"enabled": function.enabled,
|
||||
}
|
||||
for function in provider.functions
|
||||
],
|
||||
}
|
||||
|
||||
def after_start(self):
|
||||
"""Rehydrate active connectors into the runtime connector manager."""
|
||||
@@ -199,6 +322,8 @@ class ConnectorService(
|
||||
)
|
||||
|
||||
for connector in active_connectors:
|
||||
if self._is_native_type(connector.get("connector_type")):
|
||||
continue
|
||||
try:
|
||||
credentials = self._credential_store.decrypt(
|
||||
connector["encrypted_credentials"],
|
||||
@@ -280,6 +405,9 @@ class ConnectorService(
|
||||
Returns:
|
||||
ConnectorResponse: The created connector response.
|
||||
"""
|
||||
if self._is_native_type(request.connector_type):
|
||||
return self._create_native_connector(request)
|
||||
|
||||
connector_id = str(uuid.uuid4())
|
||||
encryption_salt = self._credential_store.generate_salt()
|
||||
encrypted_credentials = self._credential_store.encrypt(
|
||||
@@ -412,12 +540,23 @@ class ConnectorService(
|
||||
connector_id,
|
||||
)
|
||||
existing = {}
|
||||
# New fields override existing; existing fields survive when
|
||||
# new value is empty/missing.
|
||||
merged = {
|
||||
**existing,
|
||||
**{k: str(v) for k, v in request.credentials.items()},
|
||||
}
|
||||
if self._is_native_type(entity.connector_type):
|
||||
self._require_durable_native_key()
|
||||
loop = get_or_create_event_loop()
|
||||
probed = loop.run_until_complete(
|
||||
self._native_executor().probe(entity.connector_type, merged)
|
||||
)
|
||||
if not probed.get("success"):
|
||||
status_code = probed.get("status_code")
|
||||
raise ConnectorProbeFailed(
|
||||
str(probed.get("message") or "Connection test failed"),
|
||||
status_code=status_code if isinstance(status_code, int) else None,
|
||||
error_type=str(probed.get("error_type") or "") or None,
|
||||
)
|
||||
entity.encryption_salt = self._credential_store.generate_salt()
|
||||
entity.encrypted_credentials = self._credential_store.encrypt(
|
||||
merged,
|
||||
@@ -430,9 +569,10 @@ class ConnectorService(
|
||||
entity.config_json = json.dumps(request.config)
|
||||
config_changed = True
|
||||
|
||||
# P0-1 fix: re-activate manager if credentials/config changed,
|
||||
# so the connector becomes usable AND the status reflects reality.
|
||||
if credentials_changed or config_changed:
|
||||
if self._is_native_type(entity.connector_type):
|
||||
if credentials_changed or config_changed:
|
||||
entity.status = "active"
|
||||
elif credentials_changed or config_changed:
|
||||
previous_status = entity.status
|
||||
entity.status = "active"
|
||||
|
||||
@@ -510,6 +650,73 @@ class ConnectorService(
|
||||
raise ValueError(f"Connector '{connector_id}' not found")
|
||||
session.delete(entity)
|
||||
|
||||
def retire_connector(self, connector_id: str) -> None:
|
||||
"""Hide a connector from new chats without dropping its history row."""
|
||||
with self._dao.session() as session:
|
||||
entity = (
|
||||
session.query(ConnectorInstanceEntity)
|
||||
.filter(ConnectorInstanceEntity.connector_id == connector_id)
|
||||
.first()
|
||||
)
|
||||
if entity is None:
|
||||
raise ValueError(f"Connector '{connector_id}' not found")
|
||||
entity.status = "deleted"
|
||||
connector_manager = self.system_app.get_component(
|
||||
"connector_manager",
|
||||
ConnectorManager,
|
||||
default_component=None,
|
||||
)
|
||||
if connector_manager is None:
|
||||
return
|
||||
loop = get_or_create_event_loop()
|
||||
try:
|
||||
loop.run_until_complete(connector_manager.remove_connector(connector_id))
|
||||
except Exception:
|
||||
logger.info("Connector '%s' was not active in the MCP manager", connector_id)
|
||||
|
||||
def record_last_test(self, connector_id: str, *, success: bool) -> None:
|
||||
"""Remember the latest health check without storing credentials."""
|
||||
try:
|
||||
with self._dao.session() as session:
|
||||
entity = (
|
||||
session.query(ConnectorInstanceEntity)
|
||||
.filter(ConnectorInstanceEntity.connector_id == connector_id)
|
||||
.first()
|
||||
)
|
||||
if entity is None:
|
||||
return
|
||||
config: Dict[str, Any] = {}
|
||||
if entity.config_json:
|
||||
try:
|
||||
loaded = json.loads(entity.config_json)
|
||||
if isinstance(loaded, dict):
|
||||
config = loaded
|
||||
except Exception:
|
||||
config = {}
|
||||
config["last_tested_at"] = _utc_stamp()
|
||||
config["last_test_success"] = success
|
||||
entity.config_json = json.dumps(config)
|
||||
except Exception:
|
||||
logger.exception("Failed to record the last test for '%s'", connector_id)
|
||||
|
||||
async def test_native_credentials(
|
||||
self, connector_type: str, credentials: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Probe a native provider without writing a connector row."""
|
||||
if not self._is_native_type(connector_type):
|
||||
raise ValueError("Unknown native provider")
|
||||
from kamidso.native_connectors.registry import get_registry
|
||||
|
||||
provider = get_registry().get(connector_type)
|
||||
cleaned = {
|
||||
str(key): str(value)
|
||||
for key, value in credentials.items()
|
||||
if str(value).strip()
|
||||
}
|
||||
if not str(cleaned.get(provider.credential_field) or "").strip():
|
||||
raise ValueError(f"Missing credential field: {provider.credential_field}")
|
||||
return await self._native_executor().probe(connector_type, cleaned)
|
||||
|
||||
def list_tools(self, connector_id: str) -> ConnectorToolsResponse:
|
||||
"""Return the tool summaries for a single connector.
|
||||
|
||||
@@ -528,6 +735,13 @@ class ConnectorService(
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Connector '{connector_id}' not found"
|
||||
)
|
||||
current = self.get_connector(connector_id)
|
||||
if current is not None and self._is_native_type(current.connector_type):
|
||||
return ConnectorToolsResponse(
|
||||
connector_id=connector_id,
|
||||
state="not_mcp",
|
||||
tools=[],
|
||||
)
|
||||
|
||||
connector_manager = self.system_app.get_component(
|
||||
"connector_manager",
|
||||
@@ -625,6 +839,22 @@ class ConnectorService(
|
||||
if connector is None:
|
||||
raise ValueError(f"Connector '{connector_id}' not found")
|
||||
|
||||
if self._is_native_type(connector.connector_type):
|
||||
probed = await self._native_executor().probe_stored(connector_id)
|
||||
success = bool(probed.get("success"))
|
||||
if success:
|
||||
self._heal_status_to_active(connector_id)
|
||||
self.record_last_test(connector_id, success=success)
|
||||
result: Dict[str, Any] = {
|
||||
"success": success,
|
||||
"message": str(probed.get("message") or ""),
|
||||
}
|
||||
if isinstance(probed.get("status_code"), int):
|
||||
result["status_code"] = probed["status_code"]
|
||||
if probed.get("error_type"):
|
||||
result["error_type"] = probed["error_type"]
|
||||
return result
|
||||
|
||||
cm = self.system_app.get_component(
|
||||
"connector_manager", ConnectorManager, default_component=None
|
||||
)
|
||||
|
||||
@@ -1163,3 +1163,160 @@ def test_list_tools_p95_latency_under_100ms_for_50_tools(
|
||||
samples_ms.sort()
|
||||
p95 = samples_ms[int(len(samples_ms) * 0.95) - 1]
|
||||
assert p95 < 100.0, f"p95={p95:.2f}ms exceeds 100ms budget"
|
||||
|
||||
|
||||
def _enable_didar(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("KAMIDSO_NATIVE_CONNECTORS_V1", "1")
|
||||
monkeypatch.setenv("KAMIDSO_DIDAR_CONNECTOR", "1")
|
||||
from kamidso.native_connectors.registry import reset_registry
|
||||
|
||||
reset_registry()
|
||||
|
||||
|
||||
def test_native_create_persists_without_mcp_and_encrypts_api_key(
|
||||
service: ConnectorService, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
_enable_didar(monkeypatch)
|
||||
from kamidso.native_connectors.executor import NativeConnectorExecutor
|
||||
|
||||
async def _probe(self, provider_id, credentials):
|
||||
assert credentials["api_key"] == "didar-secret"
|
||||
return {"success": True, "message": "ok"}
|
||||
|
||||
monkeypatch.setattr(NativeConnectorExecutor, "probe", _probe)
|
||||
fake_manager = MagicMock()
|
||||
fake_manager.create_connector = AsyncMock()
|
||||
|
||||
def _fake_get_component(name, component_type, default_component=None):
|
||||
if name == "connector_manager":
|
||||
return fake_manager
|
||||
return default_component
|
||||
|
||||
monkeypatch.setattr(service.system_app, "get_component", _fake_get_component)
|
||||
response = service.create_connector(
|
||||
ConnectorCreateRequest(
|
||||
connector_type="didar",
|
||||
display_name="دیدار فروش",
|
||||
credentials={"api_key": "didar-secret"},
|
||||
user_name="usr_test",
|
||||
)
|
||||
)
|
||||
fake_manager.create_connector.assert_not_called()
|
||||
assert response.status == "active"
|
||||
assert "didar-secret" not in response.model_dump_json()
|
||||
with db.session() as session:
|
||||
entity = (
|
||||
session.query(ConnectorInstanceEntity)
|
||||
.filter(ConnectorInstanceEntity.connector_id == response.connector_id)
|
||||
.first()
|
||||
)
|
||||
assert entity is not None
|
||||
assert entity.connector_type == "didar"
|
||||
assert "didar-secret" not in (entity.encrypted_credentials or "")
|
||||
store = CredentialStore(system_app=service.system_app)
|
||||
assert store.decrypt(entity.encrypted_credentials, entity.encryption_salt) == {
|
||||
"api_key": "didar-secret"
|
||||
}
|
||||
tools = service.list_tools(response.connector_id)
|
||||
assert tools.state == "not_mcp"
|
||||
assert tools.tools == []
|
||||
functions = service.list_native_functions(response.connector_id)
|
||||
assert functions["provider"] == "didar"
|
||||
assert any(item["name"] == "search_deals" and item["enabled"] for item in functions["functions"])
|
||||
assert "input_schema" not in json.dumps(functions)
|
||||
|
||||
|
||||
def test_native_create_does_not_persist_when_health_check_fails(
|
||||
service: ConnectorService, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
_enable_didar(monkeypatch)
|
||||
from kamidso.native_connectors.executor import NativeConnectorExecutor
|
||||
|
||||
async def _probe(self, provider_id, credentials):
|
||||
return {"success": False, "message": "invalid api key"}
|
||||
|
||||
monkeypatch.setattr(NativeConnectorExecutor, "probe", _probe)
|
||||
with pytest.raises(ValueError, match="invalid api key"):
|
||||
service.create_connector(
|
||||
ConnectorCreateRequest(
|
||||
connector_type="didar",
|
||||
display_name="دیدار فروش",
|
||||
credentials={"api_key": "bad"},
|
||||
)
|
||||
)
|
||||
with db.session() as session:
|
||||
assert session.query(ConnectorInstanceEntity).count() == 0
|
||||
|
||||
|
||||
def test_native_create_requires_durable_key_in_production(
|
||||
service: ConnectorService, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
_enable_didar(monkeypatch)
|
||||
monkeypatch.setenv("KAMIDSO_ENV", "production")
|
||||
monkeypatch.setattr(service._credential_store, "has_durable_key", lambda: False)
|
||||
from kamidso.native_connectors.executor import NativeConnectorExecutor
|
||||
|
||||
called = {"probe": False}
|
||||
|
||||
async def _probe(self, provider_id, credentials):
|
||||
called["probe"] = True
|
||||
return {"success": True, "message": "ok"}
|
||||
|
||||
monkeypatch.setattr(NativeConnectorExecutor, "probe", _probe)
|
||||
with pytest.raises(ValueError, match="ENCRYPT_KEY"):
|
||||
service.create_connector(
|
||||
ConnectorCreateRequest(
|
||||
connector_type="didar",
|
||||
display_name="دیدار فروش",
|
||||
credentials={"api_key": "didar-secret"},
|
||||
)
|
||||
)
|
||||
assert called["probe"] is False
|
||||
|
||||
|
||||
def test_after_start_skips_native_connectors(
|
||||
service: ConnectorService, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
_enable_didar(monkeypatch)
|
||||
store = CredentialStore(system_app=service.system_app)
|
||||
salt = store.generate_salt()
|
||||
with db.session() as session:
|
||||
session.add(
|
||||
ConnectorInstanceEntity(
|
||||
connector_id="didar-1",
|
||||
connector_type="didar",
|
||||
display_name="دیدار فروش",
|
||||
encrypted_credentials=store.encrypt({"api_key": "didar-secret"}, salt),
|
||||
encryption_salt=salt,
|
||||
status="active",
|
||||
)
|
||||
)
|
||||
fake_manager = MagicMock()
|
||||
fake_manager.create_connector = AsyncMock()
|
||||
|
||||
def _fake_get_component(name, component_type, default_component=None):
|
||||
if name == "connector_manager":
|
||||
return fake_manager
|
||||
return default_component
|
||||
|
||||
monkeypatch.setattr(service.system_app, "get_component", _fake_get_component)
|
||||
service.after_start()
|
||||
fake_manager.create_connector.assert_not_called()
|
||||
|
||||
|
||||
def test_retire_connector_keeps_the_row(service: ConnectorService):
|
||||
with db.session() as session:
|
||||
session.add(
|
||||
ConnectorInstanceEntity(
|
||||
connector_id="didar-keep",
|
||||
connector_type="didar",
|
||||
display_name="دیدار فروش",
|
||||
status="active",
|
||||
user_name="usr_1",
|
||||
)
|
||||
)
|
||||
service.retire_connector("didar-keep")
|
||||
kept = service.get_connector("didar-keep")
|
||||
assert kept is not None
|
||||
assert kept.status == "deleted"
|
||||
|
||||
@@ -13,7 +13,11 @@ from kamidso.contracts.chat import KamidsoChatRequest
|
||||
from kamidso.contracts.resource import ResourceRef
|
||||
from kamidso.contracts.run import KamidsoRun
|
||||
from kamidso.contracts.user import CurrentUser
|
||||
from kamidso.integrations.engine.events import map_engine_event
|
||||
from kamidso.integrations.engine.events import (
|
||||
begin_adapter_run,
|
||||
map_engine_event,
|
||||
release_adapter_run,
|
||||
)
|
||||
from kamidso.integrations.engine.runtime import EngineRuntime
|
||||
from kamidso.resources.resolver import MaterializedResource
|
||||
from kamidso.settings.flags import KamidsoSettings
|
||||
@@ -74,6 +78,32 @@ class KamidsoEngine:
|
||||
}
|
||||
return
|
||||
|
||||
begin_adapter_run(run.id)
|
||||
try:
|
||||
async for event in self._execute_ready(
|
||||
user=user,
|
||||
run=run,
|
||||
request=request,
|
||||
materialized=materialized,
|
||||
prior_history=prior_history,
|
||||
last_report=last_report,
|
||||
):
|
||||
yield event
|
||||
finally:
|
||||
# Failure, cancellation, and client disconnect all land here,
|
||||
# after the last event has already been yielded.
|
||||
release_adapter_run(run.id)
|
||||
|
||||
async def _execute_ready(
|
||||
self,
|
||||
*,
|
||||
user: CurrentUser,
|
||||
run: KamidsoRun,
|
||||
request: KamidsoChatRequest,
|
||||
materialized: List[MaterializedResource],
|
||||
prior_history: Optional[List[Dict[str, str]]] = None,
|
||||
last_report: Optional[Dict[str, Any]] = None,
|
||||
) -> AsyncIterator[Dict[str, Any]]:
|
||||
from kamidso.engine.app.openapi.api_v1.agentic_data_api import (
|
||||
_open_turn_attachments,
|
||||
_react_agent_stream,
|
||||
@@ -109,6 +139,9 @@ class KamidsoEngine:
|
||||
ext_info["knowledge_space_name"] = kb_ids[0]
|
||||
if kb_label:
|
||||
ext_info["knowledge_space_label"] = kb_label
|
||||
connector_refs = [ref for ref in request.resources if ref.type == "connector" and ref.id]
|
||||
if connector_refs:
|
||||
ext_info.update(connector_selection(connector_refs))
|
||||
|
||||
context = self._builder.build(
|
||||
user,
|
||||
@@ -226,6 +259,15 @@ class KamidsoEngine:
|
||||
logger.warning("Failed to close ReAct agent stream", exc_info=True)
|
||||
|
||||
|
||||
def connector_selection(resources: List[Any]) -> Dict[str, List[str]]:
|
||||
"""Map attached connector resources onto the existing connector_ids field."""
|
||||
refs = [ref for ref in resources if getattr(ref, "type", None) == "connector" and getattr(ref, "id", None)]
|
||||
return {
|
||||
"connector_ids": [ref.id for ref in refs],
|
||||
"connector_labels": [ref.name or ref.id for ref in refs],
|
||||
}
|
||||
|
||||
|
||||
def _stage_files_for_engine(
|
||||
materialized: List[MaterializedResource],
|
||||
) -> tuple[List[str], List[str]]:
|
||||
|
||||
@@ -1,15 +1,121 @@
|
||||
"""Map engine SSE payloads to product events, and forget finished runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from kamidso.integrations.event_adapter import EventAdapterState
|
||||
|
||||
logger = logging.getLogger("kamidso.engine")
|
||||
|
||||
_ADAPTER_CACHE: Dict[str, EventAdapterState] = {}
|
||||
_ACTIVE_RUNS: set[str] = set()
|
||||
_TOUCHED: Dict[str, float] = {}
|
||||
_LOCK = threading.Lock()
|
||||
_SWEEPER_STARTED = False
|
||||
_DEFAULT_TTL_SECONDS = 900.0
|
||||
|
||||
|
||||
def adapter_cache_ttl_seconds() -> float:
|
||||
raw = (os.environ.get("KAMIDSO_ADAPTER_CACHE_TTL_SECONDS") or "").strip()
|
||||
if not raw:
|
||||
return _DEFAULT_TTL_SECONDS
|
||||
try:
|
||||
return max(1.0, float(raw))
|
||||
except ValueError:
|
||||
logger.warning("Ignoring invalid KAMIDSO_ADAPTER_CACHE_TTL_SECONDS=%s", raw)
|
||||
return _DEFAULT_TTL_SECONDS
|
||||
|
||||
|
||||
def begin_adapter_run(run_id: str) -> None:
|
||||
"""Keep this run's adapter alive for the whole execution, including long tools."""
|
||||
with _LOCK:
|
||||
_ACTIVE_RUNS.add(run_id)
|
||||
_TOUCHED[run_id] = time.monotonic()
|
||||
_ensure_sweeper_locked()
|
||||
|
||||
|
||||
def release_adapter_run(run_id: str) -> None:
|
||||
"""Drop adapter state after the last event for this run has been produced.
|
||||
|
||||
Safe on success, failure, cancellation, and generator close. Does not
|
||||
remove a different run that is still active.
|
||||
"""
|
||||
with _LOCK:
|
||||
_ACTIVE_RUNS.discard(run_id)
|
||||
_ADAPTER_CACHE.pop(run_id, None)
|
||||
_TOUCHED.pop(run_id, None)
|
||||
|
||||
|
||||
def sweep_adapter_cache(*, now: Optional[float] = None, keep: Optional[str] = None) -> List[str]:
|
||||
"""Forget inactive adapters that have not been touched within the TTL.
|
||||
|
||||
Active runs are kept even if they have been quiet, so a long SQL or code
|
||||
step is not discarded mid-flight.
|
||||
"""
|
||||
moment = time.monotonic() if now is None else now
|
||||
ttl = adapter_cache_ttl_seconds()
|
||||
removed: List[str] = []
|
||||
with _LOCK:
|
||||
stale = [
|
||||
run_id
|
||||
for run_id, touched in _TOUCHED.items()
|
||||
if run_id != keep and run_id not in _ACTIVE_RUNS and moment - touched >= ttl
|
||||
]
|
||||
for run_id in stale:
|
||||
_ADAPTER_CACHE.pop(run_id, None)
|
||||
_TOUCHED.pop(run_id, None)
|
||||
removed.append(run_id)
|
||||
return removed
|
||||
|
||||
|
||||
def map_engine_event(
|
||||
run_id: str, engine_event: Dict[str, Any], locale: str = "fa-IR"
|
||||
) -> List[Dict[str, Any]]:
|
||||
adapter = _ADAPTER_CACHE.setdefault(run_id, EventAdapterState(run_id, locale=locale))
|
||||
return adapter.map(engine_event)
|
||||
with _LOCK:
|
||||
_TOUCHED[run_id] = time.monotonic()
|
||||
adapter = _ADAPTER_CACHE.get(run_id)
|
||||
if adapter is None:
|
||||
adapter = EventAdapterState(run_id, locale=locale)
|
||||
_ADAPTER_CACHE[run_id] = adapter
|
||||
_ensure_sweeper_locked()
|
||||
events = adapter.map(engine_event)
|
||||
sweep_adapter_cache(keep=run_id)
|
||||
return events
|
||||
|
||||
|
||||
_ADAPTER_CACHE: Dict[str, EventAdapterState] = {}
|
||||
def adapter_cache_size() -> int:
|
||||
with _LOCK:
|
||||
return len(_ADAPTER_CACHE)
|
||||
|
||||
|
||||
def adapter_run_is_active(run_id: str) -> bool:
|
||||
with _LOCK:
|
||||
return run_id in _ACTIVE_RUNS
|
||||
|
||||
|
||||
def _ensure_sweeper_locked() -> None:
|
||||
global _SWEEPER_STARTED
|
||||
if _SWEEPER_STARTED:
|
||||
return
|
||||
_SWEEPER_STARTED = True
|
||||
thread = threading.Thread(
|
||||
target=_sweep_loop,
|
||||
name="kamidso-adapter-cache",
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
|
||||
def _sweep_loop() -> None:
|
||||
while True:
|
||||
ttl = adapter_cache_ttl_seconds()
|
||||
time.sleep(min(60.0, ttl))
|
||||
try:
|
||||
sweep_adapter_cache()
|
||||
except Exception:
|
||||
logger.debug("adapter cache sweep failed", exc_info=True)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Predefined business-app connectors. Independent of MCP."""
|
||||
|
||||
from kamidso.native_connectors.registry import get_registry, reset_registry
|
||||
|
||||
__all__ = ["get_registry", "reset_registry"]
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Error taxonomy for native connector calls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class NativeConnectorError(Exception):
|
||||
error_type = "connector_error"
|
||||
http_sent = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
function: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.function = function
|
||||
self.details = details or {}
|
||||
|
||||
def to_payload(self) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"ok": False,
|
||||
"error_type": self.error_type,
|
||||
"message": self.message,
|
||||
"http_sent": self.http_sent,
|
||||
}
|
||||
if self.function:
|
||||
payload["function"] = self.function
|
||||
payload.update(self.details)
|
||||
return payload
|
||||
|
||||
|
||||
class CatalogError(NativeConnectorError):
|
||||
error_type = "catalog_error"
|
||||
|
||||
|
||||
class ConnectorNotAttached(NativeConnectorError):
|
||||
error_type = "connector_not_attached"
|
||||
|
||||
|
||||
class ConnectorNotFound(NativeConnectorError):
|
||||
error_type = "connector_not_found"
|
||||
|
||||
|
||||
class ConnectorAmbiguous(NativeConnectorError):
|
||||
error_type = "connector_ambiguous"
|
||||
|
||||
def __init__(self, names: List[str]) -> None:
|
||||
listing = "\n".join(f"- {name}" for name in names)
|
||||
super().__init__(
|
||||
"Several native connectors are attached.\n\nSpecify connection:\n" + listing,
|
||||
details={"connections": names},
|
||||
)
|
||||
|
||||
|
||||
class ProviderNotFound(NativeConnectorError):
|
||||
error_type = "provider_not_found"
|
||||
|
||||
|
||||
class FunctionNotFound(NativeConnectorError):
|
||||
error_type = "unknown_function"
|
||||
|
||||
def __init__(self, function: str, provider_label: str, suggestions: List[str]) -> None:
|
||||
super().__init__(
|
||||
f"Function '{function}' does not exist for {provider_label}.",
|
||||
function=function,
|
||||
details={
|
||||
"suggestions": suggestions,
|
||||
"instruction": "Use only predefined function names.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class FunctionDisabled(NativeConnectorError):
|
||||
error_type = "function_disabled"
|
||||
|
||||
|
||||
class OperationNotAllowed(NativeConnectorError):
|
||||
error_type = "operation_not_allowed"
|
||||
|
||||
def __init__(self, function: str, effect: str) -> None:
|
||||
super().__init__(
|
||||
"This connector is read-only. No HTTP request was sent.",
|
||||
function=function,
|
||||
details={"effect": effect},
|
||||
)
|
||||
|
||||
|
||||
class ValidationFailed(NativeConnectorError):
|
||||
error_type = "validation_error"
|
||||
|
||||
|
||||
class AuthenticationFailed(NativeConnectorError):
|
||||
"""Local credential failure. Remote 401 uses RemoteCallFailed."""
|
||||
|
||||
error_type = "authentication_error"
|
||||
|
||||
|
||||
class RemoteCallFailed(NativeConnectorError):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
error_type: str,
|
||||
function: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
super().__init__(message, function=function, details=details)
|
||||
self.error_type = error_type
|
||||
self.http_sent = True
|
||||
|
||||
|
||||
class TransportFailed(NativeConnectorError):
|
||||
error_type = "transport_error"
|
||||
|
||||
|
||||
class CallTimeout(NativeConnectorError):
|
||||
error_type = "timeout_error"
|
||||
|
||||
|
||||
class ResponseTooLarge(NativeConnectorError):
|
||||
error_type = "response_too_large"
|
||||
http_sent = True
|
||||
|
||||
|
||||
class InvalidJSONResponse(NativeConnectorError):
|
||||
error_type = "invalid_json_response"
|
||||
http_sent = True
|
||||
|
||||
|
||||
class ResultMaterializationError(NativeConnectorError):
|
||||
error_type = "result_materialization_error"
|
||||
http_sent = True
|
||||
|
||||
|
||||
class AccuracyGuard(NativeConnectorError):
|
||||
"""An exact management figure cannot be verified. No substitute total is included."""
|
||||
|
||||
error_type = "accuracy_guard"
|
||||
http_sent = True
|
||||
@@ -0,0 +1,482 @@
|
||||
"""Call a predefined native connector function. Credentials stay inside this module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from kamidso.native_connectors.errors import (
|
||||
AuthenticationFailed,
|
||||
CallTimeout,
|
||||
ConnectorNotFound,
|
||||
FunctionDisabled,
|
||||
FunctionNotFound,
|
||||
InvalidJSONResponse,
|
||||
NativeConnectorError,
|
||||
OperationNotAllowed,
|
||||
ProviderNotFound,
|
||||
RemoteCallFailed,
|
||||
ResponseTooLarge,
|
||||
ResultMaterializationError,
|
||||
TransportFailed,
|
||||
)
|
||||
from kamidso.native_connectors.models import ConnectorFunction, NativeProvider
|
||||
from kamidso.native_connectors.redact import redact, redact_text, strip_sensitive_fields
|
||||
from kamidso.native_connectors.registry import NativeConnectorRegistry, get_registry
|
||||
from kamidso.native_connectors.result_store import (
|
||||
materialize_connector_result,
|
||||
render_success_observation,
|
||||
)
|
||||
from kamidso.native_connectors.validator import validate_body, validation_payload
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_RESPONSE_BYTES = 5 * 1024 * 1024
|
||||
_ATTEMPT_TIMEOUT = 30.0
|
||||
_TOTAL_BUDGET = 60.0
|
||||
_RETRY_STATUSES = {500, 502, 503}
|
||||
RowLoader = Callable[[str], Optional[Dict[str, Any]]]
|
||||
|
||||
|
||||
def _status_error_type(status_code: int) -> str:
|
||||
if status_code == 401:
|
||||
return "authentication_error"
|
||||
if status_code == 403:
|
||||
return "permission_error"
|
||||
if status_code == 429:
|
||||
return "rate_limit_error"
|
||||
if status_code == 400:
|
||||
return "remote_validation_error"
|
||||
if status_code >= 500:
|
||||
return "remote_server_error"
|
||||
return "http_error"
|
||||
|
||||
|
||||
def suggest_functions(provider: NativeProvider, name: str) -> list:
|
||||
import difflib
|
||||
|
||||
enabled = [item.name for item in provider.enabled_reads()]
|
||||
suggestions = list(provider.aliases.get(name) or [])
|
||||
if "lead" in name.lower():
|
||||
for candidate in ("search_persons", "search_deals"):
|
||||
if candidate in enabled and candidate not in suggestions:
|
||||
suggestions.append(candidate)
|
||||
close = difflib.get_close_matches(name, enabled, n=3, cutoff=0.5)
|
||||
for candidate in close:
|
||||
if candidate not in suggestions:
|
||||
suggestions.append(candidate)
|
||||
return [item for item in suggestions if item in enabled][:3]
|
||||
|
||||
|
||||
def default_row_loader(connector_id: str) -> Optional[Dict[str, Any]]:
|
||||
from kamidso.engine.serve.connector.config import ServeConfig
|
||||
from kamidso.engine.serve.connector.models.models import (
|
||||
ConnectorInstanceDao,
|
||||
ConnectorInstanceEntity,
|
||||
)
|
||||
|
||||
dao = ConnectorInstanceDao(ServeConfig())
|
||||
with dao.session() as session:
|
||||
entity = (
|
||||
session.query(ConnectorInstanceEntity)
|
||||
.filter(ConnectorInstanceEntity.connector_id == connector_id)
|
||||
.first()
|
||||
)
|
||||
if entity is None:
|
||||
return None
|
||||
config: Dict[str, Any] = {}
|
||||
raw_config = entity.config_json
|
||||
if isinstance(raw_config, str) and raw_config.strip():
|
||||
try:
|
||||
parsed = json.loads(raw_config)
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
if isinstance(parsed, dict):
|
||||
config = parsed
|
||||
return {
|
||||
"connector_id": entity.connector_id,
|
||||
"connector_type": entity.connector_type,
|
||||
"display_name": entity.display_name or "",
|
||||
"status": entity.status or "",
|
||||
"encrypted_credentials": entity.encrypted_credentials,
|
||||
"encryption_salt": entity.encryption_salt,
|
||||
"user_name": entity.user_name,
|
||||
"sys_code": entity.sys_code,
|
||||
"config": config,
|
||||
}
|
||||
|
||||
|
||||
class NativeConnectorExecutor:
|
||||
def __init__(
|
||||
self,
|
||||
registry: Optional[NativeConnectorRegistry] = None,
|
||||
*,
|
||||
client: Optional[httpx.AsyncClient] = None,
|
||||
credential_store: Any = None,
|
||||
row_loader: Optional[RowLoader] = None,
|
||||
) -> None:
|
||||
self._registry = registry
|
||||
self._client = client
|
||||
self._credential_store = credential_store
|
||||
self._row_loader = row_loader or default_row_loader
|
||||
|
||||
def load_row(self, connector_id: str) -> Optional[Dict[str, Any]]:
|
||||
return self._row_loader(connector_id)
|
||||
|
||||
@property
|
||||
def registry(self) -> NativeConnectorRegistry:
|
||||
if self._registry is None:
|
||||
self._registry = get_registry()
|
||||
return self._registry
|
||||
|
||||
async def probe(self, provider_id: str, credentials: Dict[str, str]) -> Dict[str, Any]:
|
||||
try:
|
||||
provider = self.registry.get(provider_id)
|
||||
except ProviderNotFound as exc:
|
||||
return {"success": False, "message": exc.message}
|
||||
try:
|
||||
result = await self._call(
|
||||
provider=provider,
|
||||
function_name=provider.health_function,
|
||||
body=dict(provider.health_body),
|
||||
credentials=credentials,
|
||||
react_state=None,
|
||||
materialize=False,
|
||||
display_name=provider.display_name,
|
||||
)
|
||||
except NativeConnectorError as exc:
|
||||
payload = exc.to_payload()
|
||||
status_code = payload.get("status_code")
|
||||
return {
|
||||
"success": False,
|
||||
"message": redact_text(exc.message),
|
||||
"status_code": status_code if isinstance(status_code, int) else None,
|
||||
"error_type": exc.error_type,
|
||||
}
|
||||
if result.get("ok"):
|
||||
return {"success": True, "message": "Connection successful"}
|
||||
return {
|
||||
"success": False,
|
||||
"message": redact_text(str(result.get("message") or "Connection test failed")),
|
||||
}
|
||||
|
||||
async def probe_stored(self, connector_id: str) -> Dict[str, Any]:
|
||||
row = self._row_loader(connector_id)
|
||||
if row is None:
|
||||
return {"success": False, "message": f"Connector '{connector_id}' not found"}
|
||||
try:
|
||||
credentials = self._decrypt(row)
|
||||
except NativeConnectorError as exc:
|
||||
return {"success": False, "message": exc.message}
|
||||
return await self.probe(str(row.get("connector_type") or ""), credentials)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
connector_id: str,
|
||||
function_name: str,
|
||||
body: Any,
|
||||
react_state: Optional[Dict[str, Any]],
|
||||
display_name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
row = self._row_loader(connector_id)
|
||||
if row is None or str(row.get("status") or "") != "active":
|
||||
raise ConnectorNotFound(f"Connector '{connector_id}' is not available.")
|
||||
provider = self.registry.get(str(row.get("connector_type") or ""))
|
||||
label = display_name or str(row.get("display_name") or provider.display_name)
|
||||
return await self._call(
|
||||
provider=provider,
|
||||
function_name=function_name,
|
||||
body=body,
|
||||
credentials=None,
|
||||
react_state=react_state,
|
||||
materialize=True,
|
||||
display_name=label,
|
||||
audit_user=str((react_state or {}).get("user_name") or row.get("user_name") or ""),
|
||||
connector_id=connector_id,
|
||||
encrypted_row=row,
|
||||
)
|
||||
|
||||
def _decrypt(self, row: Dict[str, Any]) -> Dict[str, str]:
|
||||
if self._credential_store is None:
|
||||
raise AuthenticationFailed("Credential store is not configured.")
|
||||
try:
|
||||
decrypted = self._credential_store.decrypt(
|
||||
row.get("encrypted_credentials"),
|
||||
row.get("encryption_salt"),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise AuthenticationFailed("Stored credentials could not be decrypted.") from exc
|
||||
if not isinstance(decrypted, dict):
|
||||
raise AuthenticationFailed("Stored credentials could not be decrypted.")
|
||||
return {str(key): str(value) for key, value in decrypted.items()}
|
||||
|
||||
async def _call(
|
||||
self,
|
||||
*,
|
||||
provider: NativeProvider,
|
||||
function_name: str,
|
||||
body: Any,
|
||||
credentials: Optional[Dict[str, str]],
|
||||
react_state: Optional[Dict[str, Any]],
|
||||
materialize: bool,
|
||||
display_name: str,
|
||||
audit_user: str = "",
|
||||
connector_id: str = "",
|
||||
encrypted_row: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
function = provider.function(function_name)
|
||||
if function is None:
|
||||
raise FunctionNotFound(
|
||||
function_name,
|
||||
provider.display_name,
|
||||
suggest_functions(provider, function_name),
|
||||
)
|
||||
if function.effect != "read":
|
||||
raise OperationNotAllowed(function.name, function.effect)
|
||||
if not function.enabled:
|
||||
raise FunctionDisabled(
|
||||
f"Function '{function.name}' is disabled.",
|
||||
function=function.name,
|
||||
)
|
||||
normalized, issues = validate_body(function, body)
|
||||
if issues:
|
||||
return validation_payload(
|
||||
connector_name=display_name,
|
||||
function=function,
|
||||
issues=issues,
|
||||
)
|
||||
assert normalized is not None
|
||||
if credentials is None:
|
||||
if encrypted_row is None:
|
||||
raise AuthenticationFailed(
|
||||
"The connector credential is missing.",
|
||||
function=function.name,
|
||||
)
|
||||
credentials = self._decrypt(encrypted_row)
|
||||
secret = str(credentials.get(provider.credential_field) or "").strip()
|
||||
if not secret:
|
||||
raise AuthenticationFailed(
|
||||
"The connector credential is missing.",
|
||||
function=function.name,
|
||||
)
|
||||
started = time.perf_counter()
|
||||
response, request_size = await self._send(
|
||||
provider, function, strip_sensitive_fields(normalized), secret
|
||||
)
|
||||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||||
response_size = len(response.content or b"")
|
||||
self._audit(
|
||||
react_state=react_state,
|
||||
user_name=audit_user,
|
||||
connector_id=connector_id,
|
||||
connector_type=provider.id,
|
||||
function=function,
|
||||
status_code=response.status_code,
|
||||
duration_ms=duration_ms,
|
||||
request_size=request_size,
|
||||
response_size=response_size,
|
||||
)
|
||||
if response_size > _MAX_RESPONSE_BYTES:
|
||||
raise ResponseTooLarge(
|
||||
"Use narrower filters or pagination.",
|
||||
function=function.name,
|
||||
details={"status_code": response.status_code},
|
||||
)
|
||||
if response.status_code in {301, 302, 303, 307, 308}:
|
||||
raise RemoteCallFailed(
|
||||
"The connector refused a redirect.",
|
||||
error_type="http_error",
|
||||
function=function.name,
|
||||
details={
|
||||
"provider": provider.id,
|
||||
"status_code": response.status_code,
|
||||
"body": None,
|
||||
},
|
||||
)
|
||||
payload = self._decode_json(response, function.name)
|
||||
if response.status_code >= 400:
|
||||
details: Dict[str, Any] = {
|
||||
"provider": provider.id,
|
||||
"status_code": response.status_code,
|
||||
"body": redact(payload),
|
||||
}
|
||||
retry_after = response.headers.get("Retry-After")
|
||||
if response.status_code == 429 and retry_after:
|
||||
details["retry_after"] = retry_after
|
||||
raise RemoteCallFailed(
|
||||
f"The connector returned HTTP {response.status_code}.",
|
||||
error_type=_status_error_type(response.status_code),
|
||||
function=function.name,
|
||||
details=details,
|
||||
)
|
||||
if not materialize or react_state is None:
|
||||
return {
|
||||
"ok": True,
|
||||
"http_sent": True,
|
||||
"status_code": response.status_code,
|
||||
"body": payload,
|
||||
}
|
||||
try:
|
||||
meta = materialize_connector_result(react_state, payload)
|
||||
except OSError as exc:
|
||||
raise ResultMaterializationError(
|
||||
"The connector response could not be saved.",
|
||||
function=function.name,
|
||||
) from exc
|
||||
observation = render_success_observation(
|
||||
provider=provider,
|
||||
function=function,
|
||||
body=normalized,
|
||||
status_code=response.status_code,
|
||||
payload=payload,
|
||||
relative_path=meta["relative_path"],
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"http_sent": True,
|
||||
"status_code": response.status_code,
|
||||
"relative_path": meta["relative_path"],
|
||||
"observation": observation,
|
||||
}
|
||||
|
||||
def _decode_json(self, response: httpx.Response, function_name: str) -> Any:
|
||||
if not response.content:
|
||||
return {}
|
||||
try:
|
||||
return response.json()
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
raise InvalidJSONResponse(
|
||||
"The connector returned a non-JSON response.",
|
||||
function=function_name,
|
||||
details={"status_code": response.status_code},
|
||||
) from exc
|
||||
|
||||
async def _send(
|
||||
self,
|
||||
provider: NativeProvider,
|
||||
function: ConnectorFunction,
|
||||
body: Dict[str, Any],
|
||||
secret: str,
|
||||
):
|
||||
url = _safe_url(provider, function.path)
|
||||
params = {provider.auth_parameter: secret}
|
||||
headers = dict(provider.default_headers)
|
||||
request_size = len(json.dumps(body, ensure_ascii=False).encode("utf-8"))
|
||||
deadline = time.monotonic() + _TOTAL_BUDGET
|
||||
attempt = 0
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise CallTimeout(
|
||||
"The connector request exceeded the time budget.",
|
||||
function=function.name,
|
||||
)
|
||||
timeout = httpx.Timeout(min(_ATTEMPT_TIMEOUT, remaining))
|
||||
try:
|
||||
response = await self._request(
|
||||
function.method,
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
body=body,
|
||||
timeout=timeout,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise CallTimeout(
|
||||
"The connector request timed out.",
|
||||
function=function.name,
|
||||
) from exc
|
||||
except httpx.TransportError as exc:
|
||||
if attempt == 0 and _retryable_transport(exc) and deadline - time.monotonic() > 0:
|
||||
attempt += 1
|
||||
continue
|
||||
raise TransportFailed(
|
||||
redact_text(str(exc)) or "The connector request failed.",
|
||||
function=function.name,
|
||||
) from exc
|
||||
if response.status_code in _RETRY_STATUSES and attempt == 0 and deadline - time.monotonic() > 0:
|
||||
attempt += 1
|
||||
continue
|
||||
return response, request_size
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
params: Dict[str, str],
|
||||
headers: Dict[str, str],
|
||||
body: Dict[str, Any],
|
||||
timeout: httpx.Timeout,
|
||||
) -> httpx.Response:
|
||||
kwargs: Dict[str, Any] = {
|
||||
"params": params,
|
||||
"headers": headers,
|
||||
"timeout": timeout,
|
||||
"follow_redirects": False,
|
||||
}
|
||||
if method.upper() != "GET":
|
||||
kwargs["json"] = body
|
||||
|
||||
async def _send(client: httpx.AsyncClient) -> httpx.Response:
|
||||
return await client.request(method, url, **kwargs)
|
||||
|
||||
if self._client is not None:
|
||||
return await _send(self._client)
|
||||
async with httpx.AsyncClient(follow_redirects=False) as client:
|
||||
return await _send(client)
|
||||
|
||||
def _audit(
|
||||
self,
|
||||
*,
|
||||
react_state: Optional[Dict[str, Any]],
|
||||
user_name: str,
|
||||
connector_id: str,
|
||||
connector_type: str,
|
||||
function: ConnectorFunction,
|
||||
status_code: int,
|
||||
duration_ms: int,
|
||||
request_size: int,
|
||||
response_size: int,
|
||||
) -> None:
|
||||
logger.info(
|
||||
"native_connector conversation_id=%s user_name=%s connector_id=%s "
|
||||
"connector_type=%s function=%s effect=%s http_status=%s duration_ms=%s "
|
||||
"request_size=%s response_size=%s cache_hit=%s",
|
||||
(react_state or {}).get("conv_id") or "",
|
||||
user_name,
|
||||
connector_id,
|
||||
connector_type,
|
||||
function.name,
|
||||
function.effect,
|
||||
status_code,
|
||||
duration_ms,
|
||||
request_size,
|
||||
response_size,
|
||||
False,
|
||||
)
|
||||
|
||||
|
||||
def _retryable_transport(exc: httpx.TransportError) -> bool:
|
||||
if isinstance(exc, httpx.TimeoutException):
|
||||
return False
|
||||
return isinstance(exc, (httpx.NetworkError, httpx.RemoteProtocolError))
|
||||
|
||||
|
||||
def _safe_url(provider: NativeProvider, path: str) -> str:
|
||||
base = urlparse(provider.base_url)
|
||||
if base.scheme != "https" or not base.hostname:
|
||||
raise TransportFailed("The connector host is not allowed.")
|
||||
if not path.startswith("/") or "://" in path or ".." in path.split("/"):
|
||||
raise TransportFailed("The connector path is not allowed.")
|
||||
url = provider.base_url.rstrip("/") + path
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme != "https" or parsed.hostname != base.hostname:
|
||||
raise TransportFailed("The connector host is not allowed.")
|
||||
return url
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Feature flags for native business connectors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def _truthy(name: str, default: str = "0") -> bool:
|
||||
return os.environ.get(name, default).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def native_connectors_v1_enabled() -> bool:
|
||||
return _truthy("KAMIDSO_NATIVE_CONNECTORS_V1", "0")
|
||||
|
||||
|
||||
def didar_connector_enabled() -> bool:
|
||||
return _truthy("KAMIDSO_DIDAR_CONNECTOR", "0")
|
||||
|
||||
|
||||
def is_production() -> bool:
|
||||
return os.environ.get("KAMIDSO_ENV", "").strip().lower() == "production"
|
||||
@@ -0,0 +1,59 @@
|
||||
"""In-memory catalog models. Loaded from provider files, not from the network."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConnectorFunction:
|
||||
name: str
|
||||
title: str
|
||||
description: str
|
||||
domain: str
|
||||
effect: str
|
||||
enabled: bool
|
||||
method: str
|
||||
path: str
|
||||
input_schema: Dict[str, Any]
|
||||
response_root: str
|
||||
records_path: Optional[str] = None
|
||||
summary_fields: List[str] = field(default_factory=list)
|
||||
semantic_hints: List[str] = field(default_factory=list)
|
||||
pagination: Optional[Dict[str, Any]] = None
|
||||
content_type: str = "application/json"
|
||||
timeout_seconds: int = 30
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NativeProvider:
|
||||
id: str
|
||||
version: str
|
||||
display_name: str
|
||||
category: str
|
||||
base_url: str
|
||||
auth_type: str
|
||||
auth_parameter: str
|
||||
credential_field: str
|
||||
default_headers: Dict[str, str]
|
||||
mode: str
|
||||
health_function: str
|
||||
health_body: Dict[str, Any]
|
||||
aliases: Dict[str, List[str]]
|
||||
guidance: str
|
||||
functions: List[ConnectorFunction]
|
||||
description: str = ""
|
||||
|
||||
def function(self, name: str) -> Optional[ConnectorFunction]:
|
||||
for item in self.functions:
|
||||
if item.name == name:
|
||||
return item
|
||||
return None
|
||||
|
||||
def enabled_reads(self) -> List[ConnectorFunction]:
|
||||
return [
|
||||
item
|
||||
for item in self.functions
|
||||
if item.enabled and item.effect == "read"
|
||||
]
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Prompt text for attached native connectors. Names and descriptions only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, Iterable, List
|
||||
|
||||
from kamidso.native_connectors.models import ConnectorFunction
|
||||
from kamidso.native_connectors.registry import get_registry
|
||||
|
||||
_PATH_MENTION = re.compile(
|
||||
r"(?i)\b(?:GET|POST|PUT|PATCH|DELETE|HEAD)\s+/api/\S+|/api/\S+"
|
||||
)
|
||||
|
||||
_DOMAIN_LABELS = {
|
||||
"activity": "Activities",
|
||||
"case": "Cases",
|
||||
"company": "Companies",
|
||||
"deal": "Deals",
|
||||
"invoice": "Invoices",
|
||||
"location": "Locations",
|
||||
"note": "Notes",
|
||||
"person": "People",
|
||||
"pipeline": "Pipelines",
|
||||
"product": "Products",
|
||||
"user": "Users",
|
||||
}
|
||||
|
||||
_RULES = """Native connector functions are predefined.
|
||||
Never invent a connector function.
|
||||
Use connector_call to fetch business data.
|
||||
Do not use code_interpreter to call the external API.
|
||||
Use code_interpreter only after connector_call when computation, dataframe operations, merging, charts, statistics or export are required.
|
||||
If connector_call returns validation_error:
|
||||
1. Read the returned schema.
|
||||
2. Fix the body.
|
||||
3. Retry the SAME function.
|
||||
Do not create a different function to work around a malformed request.
|
||||
A validation_error means the selected function exists and is appropriate enough to validate.
|
||||
Prefer server-provided totals and aggregates from connector responses.
|
||||
Do not paginate through all records merely to count them.
|
||||
Didar data is fetched with connector_call. Database data uses database(). Excel and CSV files use code_interpreter.
|
||||
For Didar management questions, call a semantic operation and pass a business body. Do not build Didar Criteria.
|
||||
sales_summary answers فروش in a period and means deals that became Won in that period. Example body: {"period": "مهر ۱۴۰۵"}.
|
||||
deals_created answers معاملات in a period and means deals registered in that period. State the period basis.
|
||||
Other semantic operations: won_deals, lost_deals, pending_pipeline_snapshot, sales_by_owner, sales_over_time.
|
||||
Keep every accuracy warning in the answer, including mode, date basis, currency, and panel parity.
|
||||
Current deal status comes only from Status. ChangeToWonTime never overrides Pending.
|
||||
Response.List is one page. Totals come from aggregates such as WonDealsCount, PendingDealsCount, and LostDealsCount.
|
||||
Do not invent get_leads, search_leads, or list_leads. lead_count stays disabled.
|
||||
Do not sum amounts that have different CurrencyId values, and do not guess a currency name.
|
||||
On raw search calls, From and Limit are top-level siblings of Criteria, not fields inside Criteria.
|
||||
"""
|
||||
|
||||
|
||||
def _domain_label(domain: str) -> str:
|
||||
return _DOMAIN_LABELS.get(domain, domain.replace("_", " ").title() or "Other")
|
||||
|
||||
|
||||
def _public_description(description: str) -> str:
|
||||
cleaned = _PATH_MENTION.sub("", description)
|
||||
return " ".join(cleaned.split())
|
||||
|
||||
|
||||
def _function_lines(functions: Iterable[ConnectorFunction]) -> List[str]:
|
||||
grouped: Dict[str, List[ConnectorFunction]] = {}
|
||||
for function in functions:
|
||||
grouped.setdefault(function.domain or "other", []).append(function)
|
||||
lines: List[str] = []
|
||||
for domain, items in grouped.items():
|
||||
lines.append(_domain_label(domain))
|
||||
for function in items:
|
||||
lines.append(f"- {function.name} — {_public_description(function.description)}")
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
|
||||
def render_native_prompt(instances: List[Dict[str, Any]]) -> str:
|
||||
if not instances:
|
||||
return ""
|
||||
registry = get_registry()
|
||||
blocks: List[str] = [
|
||||
"## Connected Business Apps",
|
||||
"",
|
||||
_RULES.strip(),
|
||||
"",
|
||||
]
|
||||
by_type: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for instance in instances:
|
||||
by_type.setdefault(str(instance.get("connector_type") or ""), []).append(instance)
|
||||
for connector_type, group in by_type.items():
|
||||
if not registry.contains(connector_type):
|
||||
continue
|
||||
provider = registry.get(connector_type)
|
||||
if provider.guidance:
|
||||
blocks.append(provider.guidance)
|
||||
blocks.append("")
|
||||
for instance in group:
|
||||
blocks.extend(
|
||||
[
|
||||
f"### {instance.get('display_name') or instance.get('connector_id')}",
|
||||
f"Provider: {provider.display_name}",
|
||||
"Access: Read only",
|
||||
"",
|
||||
"Available functions:",
|
||||
"",
|
||||
]
|
||||
)
|
||||
blocks.extend(_function_lines(provider.enabled_reads()))
|
||||
return "\n".join(blocks).strip()
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"provider": "didar",
|
||||
"storage_target": "connector_instance.config_json.semantic_calibration",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"default": "Asia/Tehran"
|
||||
},
|
||||
"calendar": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"jalali",
|
||||
"gregorian"
|
||||
],
|
||||
"default": "jalali"
|
||||
},
|
||||
"base_currency_id": {
|
||||
"type": [
|
||||
"integer",
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"base_currency_code": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"base_currency_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"display_unit": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"currency_map": {
|
||||
"type": "object"
|
||||
},
|
||||
"verified_reports": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"verified": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"verified_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"panel_sample_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"notes": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"verified"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"default": {
|
||||
"timezone": "Asia/Tehran",
|
||||
"calendar": "jalali",
|
||||
"base_currency_id": null,
|
||||
"base_currency_code": null,
|
||||
"base_currency_name": null,
|
||||
"display_unit": null,
|
||||
"currency_map": {},
|
||||
"verified_reports": {}
|
||||
},
|
||||
"rule": "Calibration is connection-specific. Do not put account-specific currency mappings or verification flags in the global provider files."
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"provider": "didar",
|
||||
"default_user_calendar": "jalali",
|
||||
"default_business_timezone": "Asia/Tehran",
|
||||
"api_timestamp_timezone": "UTC",
|
||||
"range_semantics": "[start, end)",
|
||||
"parser_requirements": {
|
||||
"accept_persian_digits": true,
|
||||
"accept_latin_digits": true,
|
||||
"accept_persian_month_names": true,
|
||||
"accept_relative_periods": [
|
||||
"امروز",
|
||||
"دیروز",
|
||||
"این هفته",
|
||||
"هفته قبل",
|
||||
"این ماه",
|
||||
"ماه قبل",
|
||||
"امسال",
|
||||
"سال قبل",
|
||||
"سه ماه اخیر",
|
||||
"شش ماه اخیر"
|
||||
]
|
||||
},
|
||||
"jalali_months": {
|
||||
"فروردین": 1,
|
||||
"اردیبهشت": 2,
|
||||
"خرداد": 3,
|
||||
"تیر": 4,
|
||||
"مرداد": 5,
|
||||
"شهریور": 6,
|
||||
"مهر": 7,
|
||||
"آبان": 8,
|
||||
"آذر": 9,
|
||||
"دی": 10,
|
||||
"بهمن": 11,
|
||||
"اسفند": 12
|
||||
},
|
||||
"period_resolution": {
|
||||
"year_omitted": "Use the current Jalali year in the conversation/business timezone unless context clearly names another year.",
|
||||
"month_period": "Start at 00:00:00 local time on day 1; end at 00:00:00 local time on day 1 of the next Jalali month.",
|
||||
"utc_conversion": "Convert local start/end to UTC before calling Didar.",
|
||||
"output_metadata": [
|
||||
"input_text",
|
||||
"calendar",
|
||||
"timezone",
|
||||
"start_local",
|
||||
"end_local_exclusive",
|
||||
"start_utc",
|
||||
"end_utc_exclusive"
|
||||
]
|
||||
},
|
||||
"deal_date_basis": {
|
||||
"sales": "won_at",
|
||||
"won_deals": "won_at",
|
||||
"lost_deals": "lost_at",
|
||||
"deals_created": "created_at",
|
||||
"pending_pipeline_snapshot": "current_snapshot"
|
||||
},
|
||||
"didar_search_deals_strategy": {
|
||||
"created_at": {
|
||||
"Sort": 0,
|
||||
"SearchFromTime": "start_utc",
|
||||
"SearchToTime": "end_utc_exclusive",
|
||||
"confidence": "documented"
|
||||
},
|
||||
"won_at": {
|
||||
"Sort": 9,
|
||||
"SearchFromTime": "start_utc",
|
||||
"SearchToTime": "end_utc_exclusive",
|
||||
"confidence": "needs_real_account_reconciliation",
|
||||
"verification": [
|
||||
"Every returned row used for a won-period report must have Status == Won.",
|
||||
"Every returned row used for a won-period report must have ChangeToWonTime inside [start_utc, end_utc_exclusive).",
|
||||
"If returned rows violate the period basis, do not silently report the aggregate as exact."
|
||||
],
|
||||
"fallback": {
|
||||
"mode": "exact_local_filter_when_feasible",
|
||||
"description": "Fetch Won deals in controlled pages and post-filter by ChangeToWonTime when the server date-filter semantics are not reconciled.",
|
||||
"max_records": 5000,
|
||||
"over_cap": "Return an accuracy_guard error rather than an unverified exact number."
|
||||
}
|
||||
},
|
||||
"lost_at": {
|
||||
"confidence": "not_verified_by_public_contract",
|
||||
"fallback": "Use Status=Lost and locally filter ChangeToLossTime after controlled pagination until a real-account contract test establishes server-side date behavior."
|
||||
}
|
||||
},
|
||||
"implementation": {
|
||||
"recommended_python_dependency": "jdatetime or another tested Jalali conversion library",
|
||||
"rule": "Pin the chosen Jalali library version and cover Esfand/leap-year/month-boundary behavior with tests."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"provider": "didar",
|
||||
"authoritative": {
|
||||
"deal_status": {
|
||||
"Pending": {
|
||||
"canonical": "pending",
|
||||
"fa": "جاری",
|
||||
"meaning": "Current/open deal"
|
||||
},
|
||||
"Won": {
|
||||
"canonical": "won",
|
||||
"fa": "موفق",
|
||||
"meaning": "Won/successful deal"
|
||||
},
|
||||
"Lost": {
|
||||
"canonical": "lost",
|
||||
"fa": "ناموفق",
|
||||
"meaning": "Lost/failed deal"
|
||||
}
|
||||
},
|
||||
"deal_search_sort": {
|
||||
"0": "register_time",
|
||||
"1": "last_tracking_time",
|
||||
"2": "next_tracking_time",
|
||||
"3": "has_quote",
|
||||
"4": "has_invoice",
|
||||
"5": "without_invoice",
|
||||
"6": "has_void_invoice",
|
||||
"7": "has_quote_with_price",
|
||||
"8": "has_quote_without_price",
|
||||
"9": "won_time",
|
||||
"10": "next_activity_time"
|
||||
},
|
||||
"custom_field_operators": [
|
||||
"NotSet",
|
||||
"Set",
|
||||
"EqualToAny",
|
||||
"NotEqualAny",
|
||||
"ContainsAnyString",
|
||||
"NotContainsAnyString",
|
||||
"StartsWith",
|
||||
"LessThan",
|
||||
"GreaterThan",
|
||||
"Between"
|
||||
]
|
||||
},
|
||||
"observed_not_authoritative": {
|
||||
"contact_type": [
|
||||
"General",
|
||||
"Customer",
|
||||
"Prospect"
|
||||
],
|
||||
"lead_type_examples": [
|
||||
"General"
|
||||
],
|
||||
"lead_status_examples": [
|
||||
"None",
|
||||
"NotHandle"
|
||||
]
|
||||
},
|
||||
"rules": [
|
||||
"Deal Status is authoritative for current deal status.",
|
||||
"IsDeleted is a separate deletion dimension and must not be collapsed into deal status.",
|
||||
"Do not infer Won from PipelineStageId, Probability, labels, or ChangeToWonTime."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"provider": "didar",
|
||||
"purpose": "Merge these accuracy-critical contracts into the existing 49-function catalog. Do not replace correct existing definitions blindly.",
|
||||
"patches": [
|
||||
{
|
||||
"name": "search_deals",
|
||||
"method": "POST",
|
||||
"path": "/api/deal/search_v2",
|
||||
"effect": "read",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Criteria": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"SearchFromTime": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"SearchToTime": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"Sort": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 10
|
||||
},
|
||||
"ContactIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"CompanyIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"OtherContactIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Keywords": {
|
||||
"type": "string"
|
||||
},
|
||||
"LabelIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Pending",
|
||||
"Won",
|
||||
"Lost"
|
||||
]
|
||||
},
|
||||
"CustomFields": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"CustomFieldId": {
|
||||
"type": "string"
|
||||
},
|
||||
"OR": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"NotSet",
|
||||
"Set",
|
||||
"EqualToAny",
|
||||
"NotEqualAny",
|
||||
"ContainsAnyString",
|
||||
"NotContainsAnyString",
|
||||
"StartsWith",
|
||||
"LessThan",
|
||||
"GreaterThan",
|
||||
"Between"
|
||||
]
|
||||
},
|
||||
"Value": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Type",
|
||||
"Value"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"CustomFieldId",
|
||||
"OR"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"PipelineId": {
|
||||
"type": "string"
|
||||
},
|
||||
"PipelineStageId": {
|
||||
"type": "string"
|
||||
},
|
||||
"SourceId": {
|
||||
"type": "string"
|
||||
},
|
||||
"IsDeleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"HasActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"LostReasonId": {
|
||||
"type": "string"
|
||||
},
|
||||
"NextActivityFrom": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"NextActivityTo": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"IsIdle": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"IsRotten": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"Probability": {
|
||||
"type": "string"
|
||||
},
|
||||
"OwnerIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"PipelineStageIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"From": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"Limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Criteria",
|
||||
"From",
|
||||
"Limit"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"response_contract": {
|
||||
"root": "Response",
|
||||
"records_path": "Response.List",
|
||||
"total_count": "Response.TotalCount",
|
||||
"aggregates": [
|
||||
"Response.TotalPrice",
|
||||
"Response.AllDealsTotalPrice",
|
||||
"Response.AllDealsCount",
|
||||
"Response.PendingDealsTotalPrice",
|
||||
"Response.PendingDealsCount",
|
||||
"Response.WonDealsTotalPrice",
|
||||
"Response.WonDealsCount",
|
||||
"Response.LostDealsTotalPrice",
|
||||
"Response.LostDealsCount"
|
||||
]
|
||||
},
|
||||
"semantic_hints": [
|
||||
"From and Limit are top-level fields, not inside Criteria.",
|
||||
"Status accepts Pending, Won, Lost.",
|
||||
"Sort=0 means registration time; Sort=9 means won time.",
|
||||
"Use aggregate counts for totals; Response.List is only the current page.",
|
||||
"Current status must come from each deal's Status field."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "search_persons",
|
||||
"method": "POST",
|
||||
"path": "/api/contact/PersonSearch",
|
||||
"effect": "read",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Criteria": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"IsDeleted": {
|
||||
"type": "integer",
|
||||
"enum": [
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
"IsPinned": {
|
||||
"type": "integer"
|
||||
},
|
||||
"IsVIP": {
|
||||
"type": "integer"
|
||||
},
|
||||
"SortOrder": {
|
||||
"type": "integer"
|
||||
},
|
||||
"Keywords": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"MobilePhone": {
|
||||
"type": "string"
|
||||
},
|
||||
"WorkPhone": {
|
||||
"type": "string"
|
||||
},
|
||||
"Email": {
|
||||
"type": "string"
|
||||
},
|
||||
"NationalCode": {
|
||||
"type": "string"
|
||||
},
|
||||
"ZipCode": {
|
||||
"type": "string"
|
||||
},
|
||||
"CustomerCode": {
|
||||
"type": "string"
|
||||
},
|
||||
"SearchFromTime": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"SearchToTime": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"CustomFields": {
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"From": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"Limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Criteria",
|
||||
"From",
|
||||
"Limit"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"response_contract": {
|
||||
"root": "Response",
|
||||
"records_path": "Response.List",
|
||||
"total_count": "Response.TotalCount"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_users",
|
||||
"method": "POST",
|
||||
"path": "/api/User/List",
|
||||
"effect": "read",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"response_contract": {
|
||||
"root": "Response",
|
||||
"records_path": "Response",
|
||||
"owner_key": "UserId",
|
||||
"display_field": "DisplayName"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_deal",
|
||||
"method": "POST",
|
||||
"path": "/api/deal/getdealdetail",
|
||||
"effect": "read",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"semantic_hints": [
|
||||
"Status is authoritative for current deal status.",
|
||||
"Do not infer Won merely because ChangeToWonTime has a value; official response examples can contain ChangeToWonTime while Status is Pending."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "get_deal_pipelines",
|
||||
"method": "POST",
|
||||
"path": "/api/pipeline/list/0",
|
||||
"effect": "read",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"response_contract": {
|
||||
"root": "Response",
|
||||
"records_path": "Response",
|
||||
"pipeline_id": "Response[].Id",
|
||||
"stage_id": "Response[].Stages[].Id",
|
||||
"stage_title": "Response[].Stages[].Title"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "search_products",
|
||||
"method": "POST",
|
||||
"path": "/api/product/search",
|
||||
"effect": "read",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Criteria": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Keywords": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Keywords"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"From": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"Limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Criteria",
|
||||
"From",
|
||||
"Limit"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_product_categories",
|
||||
"method": "POST",
|
||||
"path": "/api/product/categories",
|
||||
"effect": "read",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"provider": "didar",
|
||||
"description": "Fill this file with values read from the real Didar panel for one test account. Do not commit real customer identifiers or secrets.",
|
||||
"test_account_alias": "staging-or-sanitized-account",
|
||||
"timezone": "Asia/Tehran",
|
||||
"calendar": "jalali",
|
||||
"currency_profile": {
|
||||
"base_currency_id": null,
|
||||
"base_currency_code": null,
|
||||
"base_currency_name": null,
|
||||
"display_unit": null
|
||||
},
|
||||
"periods": [
|
||||
{
|
||||
"id": "mehr_sample",
|
||||
"period_text": "مهر ۱۴۰۵",
|
||||
"panel_expected": {
|
||||
"sales_won_count": null,
|
||||
"sales_amount": null,
|
||||
"currency_display": null,
|
||||
"won_count": null,
|
||||
"lost_count": null,
|
||||
"pending_count_snapshot": null,
|
||||
"sales_by_owner": []
|
||||
},
|
||||
"kamidso_actual": {},
|
||||
"tolerance": {
|
||||
"count": 0,
|
||||
"amount_absolute": 0,
|
||||
"amount_relative": 0
|
||||
},
|
||||
"result": "not_run"
|
||||
}
|
||||
],
|
||||
"verification_rules": [
|
||||
"Counts must match exactly.",
|
||||
"Money must match within configured tolerance only after currency semantics are known.",
|
||||
"For sales reports, compare against Didar 'گزارش کلی فروش' for the same period.",
|
||||
"Record the date basis used by Kamidso.",
|
||||
"If a mismatch exists, inspect raw response, resolved UTC boundaries, Status, ChangeToWonTime, CurrencyId, and API-key visibility/access before changing semantics."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# Didar Semantic Guidance — compact provider guidance
|
||||
|
||||
این فایل برای تزریق کامل به System Prompt نیست. Runtime باید فقط بخش کوتاه و مرتبط را inject کند.
|
||||
|
||||
## قواعد قطعی
|
||||
|
||||
1. وضعیت فعلی معامله فقط از `Status` خوانده میشود:
|
||||
- `Pending` = جاری
|
||||
- `Won` = موفق
|
||||
- `Lost` = ناموفق
|
||||
2. `IsDeleted` یک بعد جدا است. حذفشده را با `Lost` یکی نکن.
|
||||
3. از `ChangeToWonTime`، `PipelineStageId`، `Probability` یا Label برای استنتاج Won/Pending استفاده نکن.
|
||||
4. `Response.List` در `search_deals` فقط صفحه فعلی است. برای شمارش کل از aggregateهای Response مانند `WonDealsCount` استفاده کن.
|
||||
5. «فروش در بازه» یعنی معاملات موفقشده در آن بازه، نه معاملات ایجادشده در آن بازه.
|
||||
6. «معاملات ثبتشده/ایجادشده در بازه» بر اساس `RegisterTime` است و وضعیت نمایشدادهشده باید وضعیت فعلی `Status` باشد.
|
||||
7. «معاملات موفق در بازه» بر اساس `ChangeToWonTime` است.
|
||||
8. «معاملات ناموفق در بازه» بر اساس `ChangeToLossTime` است.
|
||||
9. برای نام کاربران `OwnerId` را با `get_users` resolve کن؛ قرارداد اتصال را با Golden Test واقعی کنترل کن.
|
||||
10. برای نام کاریز/مرحله از `get_deal_pipelines` استفاده کن.
|
||||
|
||||
## تاریخ
|
||||
|
||||
- ورودی فارسی مثل «مهر ۱۴۰۵» را در `Asia/Tehran` و تقویم جلالی resolve کن.
|
||||
- بازه را `[start, end)` نگه دار و سپس به UTC تبدیل کن.
|
||||
- در پاسخ مدیریتی مبنای تاریخ را صریح کن: «مبنای بازه: تاریخ موفقشدن» یا «مبنای بازه: تاریخ ثبت».
|
||||
|
||||
## پول
|
||||
|
||||
- بدون mapping معتبر `CurrencyId` هرگز اسم واحد پول را حدس نزن.
|
||||
- مبالغ چند CurrencyId را خام با هم جمع نکن.
|
||||
- گزارشهای پنل دیدار از قابلیت چندارزی و ارز مبنا استفاده میکنند؛ برابری aggregate API با پنل باید با Golden Reconciliation برای اکانت تأیید شود.
|
||||
- تا قبل از Reconciliation، `FinalAdjustmentPrice` فقط انتخاب موقت رکوردی است و نباید بهعنوان تعریف ابدی «فروش خالص دیدار» hard-code شود.
|
||||
|
||||
## Lead
|
||||
|
||||
- Function مستقلی مثل `get_leads` یا `search_leads` وجود ندارد؛ آن را اختراع نکن.
|
||||
- API فیلدهای lead/contact را در Person/Contact نشان میدهد، اما یک فرمول عمومی و قطعی برای «تعداد لیدها» در مستندات عمومی تعریف نشده.
|
||||
- `lead_count` تا زمانی که با پنل واقعی حساب reconcile نشده disabled بماند.
|
||||
|
||||
## Recovery
|
||||
|
||||
- اگر raw function validation failed شد، همان function را با schema برگشتی اصلاح کن.
|
||||
- اگر semantic operation accuracy guard داد، به endpoint دیگری نپر تا فقط عددی تولید شود؛ دلیل عدم قطعیت را حفظ کن.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "didar",
|
||||
"version": "1.0.0",
|
||||
"display_name": "دیدار CRM",
|
||||
"description": "اتصال مستقیم به مشتریان، معاملات، فعالیتها، کاربران و سایر اطلاعات دیدار.",
|
||||
"category": "crm",
|
||||
"base_url": "https://app.didar.me",
|
||||
"authentication": {
|
||||
"type": "api_key_query",
|
||||
"parameter_name": "apikey",
|
||||
"credential_field": "api_key"
|
||||
},
|
||||
"default_headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"mode": "read_only",
|
||||
"health_check": {
|
||||
"function": "get_users",
|
||||
"body": {}
|
||||
},
|
||||
"aliases": {
|
||||
"get_leads": ["search_persons", "search_deals"],
|
||||
"search_leads": ["search_persons", "search_deals"],
|
||||
"list_leads": ["search_persons", "search_deals"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"provider": "didar",
|
||||
"source_fields": {
|
||||
"deal_amount": "Price",
|
||||
"deal_final_amount": "FinalAdjustmentPrice",
|
||||
"currency_id": "CurrencyId",
|
||||
"search_aggregate_total": [
|
||||
"Response.TotalPrice",
|
||||
"Response.AllDealsTotalPrice",
|
||||
"Response.PendingDealsTotalPrice",
|
||||
"Response.WonDealsTotalPrice",
|
||||
"Response.LostDealsTotalPrice"
|
||||
]
|
||||
},
|
||||
"didar_product_behavior": {
|
||||
"multi_currency_supported": true,
|
||||
"base_currency_exists": true,
|
||||
"exchange_rates_are_account_configured": true,
|
||||
"reports_can_be_viewed_by_currency": true
|
||||
},
|
||||
"safe_reporting_policy": {
|
||||
"single_currency_known_mapping": "Aggregate and show amount with the known unit/code.",
|
||||
"single_currency_unknown_mapping": "Aggregate only if business semantics otherwise permit, but label the unit as CurrencyId=<id>; do not say ریال/تومان/etc.",
|
||||
"multiple_currency_ids": "Never sum raw Price/FinalAdjustmentPrice values across currencies.",
|
||||
"server_aggregate_with_multiple_currencies": "Do not assume the API aggregate equals the Didar dashboard base-currency conversion until a golden reconciliation proves it for the account.",
|
||||
"conversion": "Only convert when the connector has a verified base-currency/exchange-rate profile sourced from a trustworthy provider surface or explicit account calibration."
|
||||
},
|
||||
"connection_calibration_fields": {
|
||||
"base_currency_id": null,
|
||||
"base_currency_code": null,
|
||||
"base_currency_name": null,
|
||||
"display_unit": null,
|
||||
"currency_map": {},
|
||||
"dashboard_aggregate_currency_semantics_verified": false
|
||||
},
|
||||
"amount_field_policy": {
|
||||
"default_record_amount": "FinalAdjustmentPrice",
|
||||
"status": "provisional_until_golden_reconciliation",
|
||||
"fallback": "Price",
|
||||
"rule": "Do not permanently hard-code which field equals Didar's dashboard sales amount until golden tests against the panel pass."
|
||||
},
|
||||
"user_facing_rules": [
|
||||
"When currency semantics are unverified, say so briefly rather than inventing a unit.",
|
||||
"If more than one CurrencyId is present and no verified conversion exists, report separate totals per CurrencyId.",
|
||||
"A successful HTTP response is not enough to call a money report accurate."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"provider": "didar",
|
||||
"semantic_operations": [
|
||||
{
|
||||
"name": "sales_summary",
|
||||
"title_fa": "خلاصه فروش",
|
||||
"enabled": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"period": {
|
||||
"type": "string"
|
||||
},
|
||||
"owner_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"pipeline_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"period"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"business_definition": "Won deals whose won time belongs to the requested period.",
|
||||
"date_basis": "won_at",
|
||||
"raw_function": "search_deals",
|
||||
"query_template": {
|
||||
"Criteria": {
|
||||
"Status": "Won",
|
||||
"Sort": 9,
|
||||
"SearchFromTime": "$period.start_utc",
|
||||
"SearchToTime": "$period.end_utc_exclusive",
|
||||
"IsDeleted": false
|
||||
},
|
||||
"From": 0,
|
||||
"Limit": 100
|
||||
},
|
||||
"preferred_server_fields": [
|
||||
"Response.WonDealsCount",
|
||||
"Response.WonDealsTotalPrice"
|
||||
],
|
||||
"accuracy": {
|
||||
"must_verify_row_status": true,
|
||||
"must_verify_won_time_range": true,
|
||||
"requires_golden_reconciliation_for_panel_parity": true,
|
||||
"do_not_count_page_list_for_total": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "won_deals",
|
||||
"title_fa": "معاملات موفق در بازه",
|
||||
"enabled": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"period": {
|
||||
"type": "string"
|
||||
},
|
||||
"owner_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 500
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"period"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"business_definition": "Deals currently marked Won whose won time is in the requested period.",
|
||||
"date_basis": "won_at",
|
||||
"raw_function": "search_deals",
|
||||
"status": "Won",
|
||||
"materialize_records": true
|
||||
},
|
||||
{
|
||||
"name": "lost_deals",
|
||||
"title_fa": "معاملات ناموفق در بازه",
|
||||
"enabled": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"period": {
|
||||
"type": "string"
|
||||
},
|
||||
"owner_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"period"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"business_definition": "Deals currently marked Lost whose loss time is in the requested period.",
|
||||
"date_basis": "lost_at",
|
||||
"raw_function": "search_deals",
|
||||
"status": "Lost",
|
||||
"accuracy": {
|
||||
"server_side_loss_date_filter_verified": false,
|
||||
"safe_mode": "post_filter_ChangeToLossTime"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "deals_created",
|
||||
"title_fa": "معاملات ثبتشده در بازه",
|
||||
"enabled": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"period": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Pending",
|
||||
"Won",
|
||||
"Lost"
|
||||
]
|
||||
},
|
||||
"owner_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"period"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"business_definition": "Deals whose RegisterTime belongs to the requested period; current status must be read from Status.",
|
||||
"date_basis": "created_at",
|
||||
"raw_function": "search_deals",
|
||||
"query_sort": 0,
|
||||
"response_disclosure": "Period is based on deal registration time."
|
||||
},
|
||||
{
|
||||
"name": "pending_pipeline_snapshot",
|
||||
"title_fa": "نمای فعلی معاملات جاری",
|
||||
"enabled": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"pipeline_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"pipeline_stage_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"business_definition": "Current snapshot of deals with Status=Pending and IsDeleted=false.",
|
||||
"date_basis": "current_snapshot",
|
||||
"raw_function": "search_deals",
|
||||
"status": "Pending"
|
||||
},
|
||||
{
|
||||
"name": "sales_by_owner",
|
||||
"title_fa": "فروش به تفکیک کارشناس",
|
||||
"enabled": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"period": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"period"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"business_definition": "Won sales in period grouped by deal OwnerId and resolved via get_users.",
|
||||
"depends_on": [
|
||||
"sales_summary",
|
||||
"get_users"
|
||||
],
|
||||
"group_by": "OwnerId",
|
||||
"owner_resolution": "get_users",
|
||||
"requires_code_interpreter": true,
|
||||
"money_policy_ref": "money_rules.safe_reporting_policy"
|
||||
},
|
||||
{
|
||||
"name": "sales_over_time",
|
||||
"title_fa": "روند فروش در زمان",
|
||||
"enabled": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"period": {
|
||||
"type": "string"
|
||||
},
|
||||
"grain": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"day",
|
||||
"week",
|
||||
"month"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"period"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"business_definition": "Won sales grouped by ChangeToWonTime.",
|
||||
"requires_records": true,
|
||||
"group_date_field": "ChangeToWonTime",
|
||||
"requires_code_interpreter": true
|
||||
},
|
||||
{
|
||||
"name": "sales_by_product",
|
||||
"title_fa": "فروش به تفکیک محصول",
|
||||
"enabled": false,
|
||||
"business_definition": "Sold quantity and amount by product for won deals in period.",
|
||||
"reason_disabled": "Search Deals may not contain complete Items for every account/result. Enable only after a verified retrieval recipe and dashboard reconciliation are implemented."
|
||||
},
|
||||
{
|
||||
"name": "sales_by_product_over_time",
|
||||
"title_fa": "فروش محصول در زمان",
|
||||
"enabled": false,
|
||||
"reason_disabled": "Requires verified product-item retrieval and currency semantics."
|
||||
},
|
||||
{
|
||||
"name": "lead_count",
|
||||
"title_fa": "تعداد سرنخ",
|
||||
"enabled": false,
|
||||
"reason_disabled": "The public API exposes LeadType/LeadStatus/ContactType examples but does not define one authoritative provider-wide lead counting rule. Calibrate against the account UI before enabling."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"provider": "didar",
|
||||
"tests": [
|
||||
{
|
||||
"id": "deal_status_authoritative",
|
||||
"input_record": {
|
||||
"Status": "Pending",
|
||||
"ChangeToWonTime": "2023-02-06T14:40:29Z",
|
||||
"Probability": 100
|
||||
},
|
||||
"expected": {
|
||||
"canonical_status": "pending"
|
||||
},
|
||||
"assertion": "ChangeToWonTime or Probability must not override Status."
|
||||
},
|
||||
{
|
||||
"id": "search_deals_page_count_not_total",
|
||||
"response": {
|
||||
"Response": {
|
||||
"WonDealsCount": 651,
|
||||
"PendingDealsCount": 0,
|
||||
"LostDealsCount": 0,
|
||||
"List": [
|
||||
{
|
||||
"Status": "Won"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"question": "چند معامله موفق داریم؟",
|
||||
"expected_count_source": "Response.WonDealsCount",
|
||||
"expected_count": 651
|
||||
},
|
||||
{
|
||||
"id": "sales_period_uses_won_time",
|
||||
"question": "فروش مهر ۱۴۰۵ چقدر بوده؟",
|
||||
"expected_operation": "sales_summary",
|
||||
"expected_date_basis": "won_at",
|
||||
"expected_status": "Won"
|
||||
},
|
||||
{
|
||||
"id": "generic_deals_period_uses_register_time",
|
||||
"question": "معاملات مهر ۱۴۰۵ را بده",
|
||||
"expected_operation": "deals_created",
|
||||
"expected_date_basis": "created_at",
|
||||
"required_disclosure": "مبنای بازه: تاریخ ثبت"
|
||||
},
|
||||
{
|
||||
"id": "multi_currency_no_raw_sum",
|
||||
"records": [
|
||||
{
|
||||
"FinalAdjustmentPrice": "100",
|
||||
"CurrencyId": 1
|
||||
},
|
||||
{
|
||||
"FinalAdjustmentPrice": "200",
|
||||
"CurrencyId": 2
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"raw_cross_currency_sum_allowed": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "unknown_currency_no_label_guess",
|
||||
"records": [
|
||||
{
|
||||
"FinalAdjustmentPrice": "100",
|
||||
"CurrencyId": 96107
|
||||
}
|
||||
],
|
||||
"calibration": {
|
||||
"currency_map": {}
|
||||
},
|
||||
"expected": {
|
||||
"may_say_toman": false,
|
||||
"may_say_rial": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lead_function_not_invented",
|
||||
"question": "تعداد لیدهای مهر را بده",
|
||||
"expected": {
|
||||
"must_not_call": [
|
||||
"get_leads",
|
||||
"search_leads",
|
||||
"list_leads"
|
||||
],
|
||||
"semantic_operation_enabled": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"provider": "didar",
|
||||
"entity_semantics": {
|
||||
"deal": {
|
||||
"identity": {
|
||||
"id": "Id",
|
||||
"code": "Code",
|
||||
"title": "Title"
|
||||
},
|
||||
"status": {
|
||||
"field": "Status",
|
||||
"enum_ref": "enums.authoritative.deal_status",
|
||||
"authoritative": true,
|
||||
"never_infer_from": [
|
||||
"PipelineStageId",
|
||||
"Probability",
|
||||
"ChangeToWonTime",
|
||||
"ChangeToLossTime",
|
||||
"LabelIds"
|
||||
]
|
||||
},
|
||||
"deletion": {
|
||||
"field": "IsDeleted",
|
||||
"separate_from_status": true
|
||||
},
|
||||
"dates": {
|
||||
"created_at": "RegisterTime",
|
||||
"won_at": "ChangeToWonTime",
|
||||
"lost_at": "ChangeToLossTime",
|
||||
"last_update_at": "LastUpdateTime",
|
||||
"next_activity_at": "NextActivityDate",
|
||||
"expected_close_at": "ExpectedCloseDate"
|
||||
},
|
||||
"money": {
|
||||
"price": "Price",
|
||||
"final_adjustment_price": "FinalAdjustmentPrice",
|
||||
"estimated_price": "EstimatedPrice",
|
||||
"currency_id": "CurrencyId",
|
||||
"safe_default_amount_field": "FinalAdjustmentPrice",
|
||||
"safe_default_is_provisional": true,
|
||||
"reason": "Exact equivalence to Didar dashboard amount must be reconciled on a real account before declaring a permanent canonical amount."
|
||||
},
|
||||
"relations": {
|
||||
"owner_id": "OwnerId",
|
||||
"owner_object": "Owner",
|
||||
"person_id": "PersonId",
|
||||
"company_id": "CompanyId",
|
||||
"pipeline_id": "PipelineId",
|
||||
"pipeline_stage_id": "PipelineStageId",
|
||||
"source_id": "SourceId",
|
||||
"lost_reason_id": "LostReasonId"
|
||||
}
|
||||
},
|
||||
"person": {
|
||||
"identity": {
|
||||
"id": "Id",
|
||||
"display_name": "DisplayName"
|
||||
},
|
||||
"created_at": "RegisterTime",
|
||||
"deleted": "IsDeleted",
|
||||
"owner_id": "OwnerId",
|
||||
"lead_fields": [
|
||||
"LeadType",
|
||||
"LeadStatus",
|
||||
"LeadGenerationType",
|
||||
"ContactType"
|
||||
],
|
||||
"lead_semantics_status": "not_fully_documented",
|
||||
"rule": "Do not implement a provider-wide lead_count until reconciled against the Didar UI/account because public endpoint examples expose lead-related fields but do not define one authoritative counting rule."
|
||||
},
|
||||
"user": {
|
||||
"resolver_function": "get_users",
|
||||
"join_rule": "Deal.OwnerId should be resolved primarily against Response[].UserId. Keep fallback to Response[].Id only if a real-account contract test proves it is required.",
|
||||
"display_field": "DisplayName"
|
||||
},
|
||||
"pipeline": {
|
||||
"resolver_function": "get_deal_pipelines",
|
||||
"pipeline_id_path": "Response[].Id",
|
||||
"stage_id_path": "Response[].Stages[].Id",
|
||||
"stage_title_path": "Response[].Stages[].Title"
|
||||
}
|
||||
},
|
||||
"business_language": {
|
||||
"فروش": "sales",
|
||||
"فروش موفق": "sales",
|
||||
"معاملات موفق": "won_deals",
|
||||
"معاملات ناموفق": "lost_deals",
|
||||
"معاملات جاری": "pending_pipeline_snapshot",
|
||||
"معاملات ثبت شده": "deals_created",
|
||||
"معاملات ایجاد شده": "deals_created"
|
||||
},
|
||||
"ambiguity_defaults": {
|
||||
"معاملات <period>": {
|
||||
"operation": "deals_created",
|
||||
"date_basis": "created_at",
|
||||
"response_disclosure": "Always state that the period basis is deal registration time."
|
||||
},
|
||||
"فروش <period>": {
|
||||
"operation": "sales_summary",
|
||||
"date_basis": "won_at",
|
||||
"response_disclosure": "Always state that sales period is based on deal won time."
|
||||
}
|
||||
},
|
||||
"accuracy_guards": [
|
||||
"Never count statuses from only Response.List when Search Deals aggregate fields are available.",
|
||||
"For current deal status, always trust Status over timestamps or stage/probability.",
|
||||
"Never label a monetary amount as ریال/تومان/USD/etc. unless the currency mapping for the connection is known.",
|
||||
"Never add raw amounts belonging to different CurrencyId values.",
|
||||
"If a semantic report has not passed reconciliation for the current connector account, use safe-mode verification or clearly report that exact parity with Didar dashboard is not yet verified."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"provider": "didar",
|
||||
"verified_at": "2026-10-12",
|
||||
"sources": [
|
||||
{
|
||||
"id": "didar_api_start",
|
||||
"url": "https://developers.didar.me/start-API",
|
||||
"purpose": "Base URL, API key authentication, request/response conventions, official endpoint index"
|
||||
},
|
||||
{
|
||||
"id": "didar_search_deals",
|
||||
"url": "https://developers.didar.me/Search-Deal",
|
||||
"purpose": "Search Deals request schema, status enum, sort values, aggregates, deal response fields"
|
||||
},
|
||||
{
|
||||
"id": "didar_get_deal",
|
||||
"url": "https://developers.didar.me/Get-Deal-By-Id",
|
||||
"purpose": "Authoritative deal detail fields and evidence that ChangeToWonTime can be populated while Status is Pending"
|
||||
},
|
||||
{
|
||||
"id": "didar_search_person",
|
||||
"url": "https://developers.didar.me/Search-Person",
|
||||
"purpose": "Person search schema, pagination, response fields including LeadType and ContactType"
|
||||
},
|
||||
{
|
||||
"id": "didar_users",
|
||||
"url": "https://developers.didar.me/User-List",
|
||||
"purpose": "User/owner resolution"
|
||||
},
|
||||
{
|
||||
"id": "didar_pipelines",
|
||||
"url": "https://developers.didar.me/List-Deal-Pipelines",
|
||||
"purpose": "Pipeline and stage resolution"
|
||||
},
|
||||
{
|
||||
"id": "didar_sales_dashboard",
|
||||
"url": "https://support.didar.me/docs/%d8%af%d8%a7%d8%b4%d8%a8%d9%88%d8%b1%d8%af-%da%af%d8%b2%d8%a7%d8%b1%d8%b4%d8%a7%d8%aa-%da%af%d8%b2%d8%a7%d8%b1%d8%b4-%da%a9%d9%84%db%8c-%d9%81%d8%b1%d9%88%d8%b4/",
|
||||
"purpose": "Business meaning of sales reports: won deals, by time, user, product, product-over-time"
|
||||
},
|
||||
{
|
||||
"id": "didar_won_date",
|
||||
"url": "https://support.didar.me/docs/%da%86%da%af%d9%88%d9%86%d9%87-%d8%aa%d8%a7%d8%b1%db%8c%d8%ae-%d9%85%d9%88%d9%81%d9%82-%d8%b4%d8%af%d9%86-%d9%85%d8%b9%d8%a7%d9%85%d9%84%d9%87-%d8%b1%d8%a7-%d8%aa%d8%ba%db%8c%db%8c%d8%b1-%d8%af%d9%87/",
|
||||
"purpose": "Meaning of deal won time"
|
||||
},
|
||||
{
|
||||
"id": "didar_deal_status_filter",
|
||||
"url": "https://support.didar.me/docs/%d9%81%db%8c%d9%84%d8%aa%d8%b1-%d9%85%d8%b9%d8%a7%d9%85%d9%84%d8%a7%d8%aa-%d8%a8%d8%b1-%d8%a7%d8%b3%d8%a7%d8%b3-%d9%86%d9%88%d8%b9-%d9%85%d8%b9%d8%a7%d9%85%d9%84%d9%87-%d9%85%d9%88%d9%81%d9%82/",
|
||||
"purpose": "UI concepts: won, lost, pending/current, deleted"
|
||||
},
|
||||
{
|
||||
"id": "didar_multi_currency",
|
||||
"url": "https://support.didar.me/docs/%d9%88%db%8c%da%98%da%af%db%8c-%da%86%d9%86%d8%af-%d8%a7%d8%b1%d8%b2%db%8c-%d8%af%d8%b1-%d8%af%db%8c%d8%af%d8%a7%d8%b1/",
|
||||
"purpose": "Base currency, exchange rate, decimal precision, report currency behavior"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Redact secrets from connector observations and logs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict
|
||||
|
||||
_SECRET_KEYS = re.compile(
|
||||
r"(apikey|api_key|authorization|set-cookie|cookie|token|password|secret)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_QUERY_SECRET = re.compile(
|
||||
r"(?i)(apikey|api_key|token|password|secret)=([^&\s\"']+)"
|
||||
)
|
||||
|
||||
|
||||
def redact_text(value: str) -> str:
|
||||
return _QUERY_SECRET.sub(r"\1=***", value)
|
||||
|
||||
|
||||
def redact(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
cleaned = {}
|
||||
for key, item in value.items():
|
||||
if _SECRET_KEYS.search(str(key)):
|
||||
cleaned[key] = "***"
|
||||
else:
|
||||
cleaned[key] = redact(item)
|
||||
return cleaned
|
||||
if isinstance(value, list):
|
||||
return [redact(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
return redact_text(value)
|
||||
return value
|
||||
|
||||
|
||||
def strip_sensitive_fields(body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
key: value
|
||||
for key, value in body.items()
|
||||
if not _SECRET_KEYS.search(str(key))
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Load and cache predefined native connector catalogs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from kamidso.native_connectors.errors import CatalogError, ProviderNotFound
|
||||
from kamidso.native_connectors.flags import (
|
||||
didar_connector_enabled,
|
||||
is_production,
|
||||
native_connectors_v1_enabled,
|
||||
)
|
||||
from kamidso.native_connectors.models import ConnectorFunction, NativeProvider
|
||||
from kamidso.native_connectors.validator import (
|
||||
check_input_schema,
|
||||
clear_validator_cache,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ALLOWED_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"}
|
||||
_ALLOWED_EFFECTS = {"read", "write", "delete"}
|
||||
_GUIDANCE_LIMIT = 8000
|
||||
_REGISTRY: Optional["NativeConnectorRegistry"] = None
|
||||
|
||||
|
||||
def _read_json(path: Path) -> object:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise CatalogError(f"Cannot read {path.name}: {exc}") from exc
|
||||
|
||||
|
||||
def function_from_raw(raw: Dict[str, object]) -> ConnectorFunction:
|
||||
transport = raw.get("transport") if isinstance(raw.get("transport"), dict) else {}
|
||||
method = str(transport.get("method") or raw.get("method") or "").upper()
|
||||
path = str(transport.get("path") or raw.get("path") or "")
|
||||
response = raw.get("response") if isinstance(raw.get("response"), dict) else {}
|
||||
root = str(response.get("root") or raw.get("response_root") or "Response")
|
||||
records_path = response.get("records_path", raw.get("records_path"))
|
||||
if records_path is not None:
|
||||
records_path = str(records_path) or None
|
||||
summary = response.get("summary_fields") or raw.get("summary_fields") or []
|
||||
pagination = raw.get("pagination") if isinstance(raw.get("pagination"), dict) else None
|
||||
if records_path is None and pagination and pagination.get("type") == "offset_limit":
|
||||
records_path = f"{root}.List"
|
||||
hints = raw.get("semantic_hints") or []
|
||||
schema = raw.get("input_schema") if isinstance(raw.get("input_schema"), dict) else {}
|
||||
return ConnectorFunction(
|
||||
name=str(raw.get("name") or ""),
|
||||
title=str(raw.get("title") or raw.get("name") or ""),
|
||||
description=str(raw.get("description") or ""),
|
||||
domain=str(raw.get("domain") or raw.get("entity") or ""),
|
||||
effect=str(raw.get("effect") or ""),
|
||||
enabled=bool(raw.get("enabled")),
|
||||
method=method,
|
||||
path=path,
|
||||
input_schema=schema,
|
||||
response_root=root,
|
||||
records_path=records_path,
|
||||
summary_fields=[str(item) for item in summary] if isinstance(summary, list) else [],
|
||||
semantic_hints=[str(item) for item in hints] if isinstance(hints, list) else [],
|
||||
pagination=pagination,
|
||||
content_type=str(raw.get("content_type") or "application/json"),
|
||||
timeout_seconds=int(raw.get("timeout_seconds") or 30),
|
||||
)
|
||||
|
||||
|
||||
def validate_provider(provider: NativeProvider) -> None:
|
||||
parsed = urlparse(provider.base_url)
|
||||
if parsed.scheme != "https" or not parsed.hostname:
|
||||
raise CatalogError(f"Provider '{provider.id}' base_url must be https with a host.")
|
||||
if provider.mode not in {"read_only", "read_write"}:
|
||||
raise CatalogError(f"Provider '{provider.id}' has unknown mode '{provider.mode}'.")
|
||||
if not provider.credential_field or not provider.auth_parameter:
|
||||
raise CatalogError(f"Provider '{provider.id}' is missing authentication fields.")
|
||||
seen = set()
|
||||
for function in provider.functions:
|
||||
if not function.name or function.name in seen:
|
||||
raise CatalogError(
|
||||
f"Provider '{provider.id}' has a duplicate or empty function name "
|
||||
f"'{function.name}'."
|
||||
)
|
||||
seen.add(function.name)
|
||||
if function.effect not in _ALLOWED_EFFECTS:
|
||||
raise CatalogError(
|
||||
f"Function '{function.name}' has unknown effect '{function.effect}'."
|
||||
)
|
||||
if function.method not in _ALLOWED_METHODS:
|
||||
raise CatalogError(
|
||||
f"Function '{function.name}' has invalid method '{function.method}'."
|
||||
)
|
||||
if (
|
||||
not function.path.startswith("/")
|
||||
or "://" in function.path
|
||||
or function.path.startswith("//")
|
||||
):
|
||||
raise CatalogError(f"Function '{function.name}' path must be relative.")
|
||||
check_input_schema(function.name, function.input_schema)
|
||||
if provider.mode == "read_only" and function.enabled and function.effect != "read":
|
||||
raise CatalogError(
|
||||
f"Function '{function.name}' is enabled but is not a read."
|
||||
)
|
||||
if provider.health_function not in seen:
|
||||
raise CatalogError(
|
||||
f"Provider '{provider.id}' health check function "
|
||||
f"'{provider.health_function}' is missing."
|
||||
)
|
||||
for alias, targets in provider.aliases.items():
|
||||
for target in targets:
|
||||
if target not in seen:
|
||||
raise CatalogError(
|
||||
f"Alias '{alias}' points at unknown function '{target}'."
|
||||
)
|
||||
|
||||
|
||||
def _merge_object_schema(base: Dict[str, object], incoming: Dict[str, object]) -> None:
|
||||
"""Add accuracy-patch fields without replacing richer existing definitions."""
|
||||
properties = base.get("properties")
|
||||
incoming_properties = incoming.get("properties")
|
||||
if not isinstance(properties, dict):
|
||||
properties = {}
|
||||
base["properties"] = properties
|
||||
if not isinstance(incoming_properties, dict):
|
||||
return
|
||||
for key, spec in incoming_properties.items():
|
||||
if not isinstance(spec, dict):
|
||||
continue
|
||||
current = properties.get(key)
|
||||
if not isinstance(current, dict):
|
||||
properties[key] = copy.deepcopy(spec)
|
||||
continue
|
||||
if current.get("type") == "object" and spec.get("type") == "object":
|
||||
_merge_object_schema(current, spec)
|
||||
continue
|
||||
if "enum" in spec and "enum" not in current:
|
||||
current["enum"] = copy.deepcopy(spec["enum"])
|
||||
if "minimum" in spec and "minimum" not in current:
|
||||
current["minimum"] = spec["minimum"]
|
||||
if "maximum" in spec and "maximum" not in current:
|
||||
current["maximum"] = spec["maximum"]
|
||||
|
||||
|
||||
def _apply_accuracy_patch(directory: Path, functions_raw: List[object]) -> None:
|
||||
path = directory / "function_accuracy_patch.json"
|
||||
if not path.is_file():
|
||||
return
|
||||
document = _read_json(path)
|
||||
if not isinstance(document, dict):
|
||||
return
|
||||
patches = document.get("patches")
|
||||
if not isinstance(patches, list):
|
||||
return
|
||||
by_name = {
|
||||
str(item.get("name")): item
|
||||
for item in functions_raw
|
||||
if isinstance(item, dict) and item.get("name")
|
||||
}
|
||||
for patch in patches:
|
||||
if not isinstance(patch, dict):
|
||||
continue
|
||||
current = by_name.get(str(patch.get("name") or ""))
|
||||
if not isinstance(current, dict):
|
||||
continue
|
||||
schema = patch.get("input_schema")
|
||||
existing_schema = current.get("input_schema")
|
||||
if isinstance(schema, dict) and isinstance(existing_schema, dict):
|
||||
_merge_object_schema(existing_schema, schema)
|
||||
hints = patch.get("semantic_hints")
|
||||
if isinstance(hints, list):
|
||||
existing_hints = current.get("semantic_hints")
|
||||
if not isinstance(existing_hints, list):
|
||||
existing_hints = []
|
||||
current["semantic_hints"] = existing_hints
|
||||
for hint in hints:
|
||||
if hint not in existing_hints:
|
||||
existing_hints.append(hint)
|
||||
contract = patch.get("response_contract")
|
||||
if not isinstance(contract, dict):
|
||||
continue
|
||||
if contract.get("records_path") and not current.get("records_path"):
|
||||
current["records_path"] = str(contract["records_path"])
|
||||
aggregates = contract.get("aggregates")
|
||||
if isinstance(aggregates, list):
|
||||
fields = current.get("summary_fields")
|
||||
if not isinstance(fields, list):
|
||||
fields = []
|
||||
current["summary_fields"] = fields
|
||||
for field in aggregates:
|
||||
if field not in fields:
|
||||
fields.append(field)
|
||||
|
||||
|
||||
def load_provider(directory: Path) -> NativeProvider:
|
||||
manifest_path = directory / "manifest.json"
|
||||
functions_path = directory / "functions.json"
|
||||
manifest = _read_json(manifest_path)
|
||||
functions_raw = _read_json(functions_path)
|
||||
if not isinstance(manifest, dict):
|
||||
raise CatalogError(f"{directory.name} manifest must be an object.")
|
||||
if not isinstance(functions_raw, list):
|
||||
raise CatalogError(f"{directory.name} functions.json must be an array.")
|
||||
_apply_accuracy_patch(directory, functions_raw)
|
||||
auth = manifest.get("authentication") if isinstance(manifest.get("authentication"), dict) else {}
|
||||
health = manifest.get("health_check") if isinstance(manifest.get("health_check"), dict) else {}
|
||||
headers = manifest.get("default_headers") if isinstance(manifest.get("default_headers"), dict) else {}
|
||||
aliases = manifest.get("aliases") if isinstance(manifest.get("aliases"), dict) else {}
|
||||
guidance_path = directory / "guidance.md"
|
||||
guidance = ""
|
||||
if guidance_path.is_file():
|
||||
guidance = guidance_path.read_text(encoding="utf-8").strip()
|
||||
if len(guidance) > _GUIDANCE_LIMIT:
|
||||
guidance = guidance[:_GUIDANCE_LIMIT]
|
||||
functions = [
|
||||
function_from_raw(item)
|
||||
for item in functions_raw
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
if len(functions) != len(functions_raw):
|
||||
raise CatalogError(f"{directory.name} functions.json contains a non-object entry.")
|
||||
normalized_aliases: Dict[str, List[str]] = {}
|
||||
for key, value in aliases.items():
|
||||
if isinstance(value, list):
|
||||
normalized_aliases[str(key)] = [str(item) for item in value]
|
||||
return NativeProvider(
|
||||
id=str(manifest.get("id") or directory.name),
|
||||
version=str(manifest.get("version") or ""),
|
||||
display_name=str(manifest.get("display_name") or manifest.get("id") or directory.name),
|
||||
category=str(manifest.get("category") or ""),
|
||||
base_url=str(manifest.get("base_url") or "").rstrip("/"),
|
||||
auth_type=str(auth.get("type") or ""),
|
||||
auth_parameter=str(auth.get("parameter_name") or ""),
|
||||
credential_field=str(auth.get("credential_field") or ""),
|
||||
default_headers={str(key): str(value) for key, value in headers.items()},
|
||||
mode=str(manifest.get("mode") or "read_only"),
|
||||
health_function=str(health.get("function") or ""),
|
||||
health_body=health.get("body") if isinstance(health.get("body"), dict) else {},
|
||||
aliases=normalized_aliases,
|
||||
guidance=guidance,
|
||||
functions=functions,
|
||||
description=str(manifest.get("description") or ""),
|
||||
)
|
||||
|
||||
|
||||
class NativeConnectorRegistry:
|
||||
def __init__(self, providers: Dict[str, NativeProvider]) -> None:
|
||||
self._providers = dict(providers)
|
||||
|
||||
def contains(self, connector_type: Optional[str]) -> bool:
|
||||
return bool(connector_type) and connector_type in self._providers
|
||||
|
||||
def get(self, connector_type: str) -> NativeProvider:
|
||||
provider = self._providers.get(connector_type)
|
||||
if provider is None:
|
||||
raise ProviderNotFound(f"Native provider '{connector_type}' is not available.")
|
||||
return provider
|
||||
|
||||
def provider_ids(self) -> List[str]:
|
||||
return sorted(self._providers)
|
||||
|
||||
@classmethod
|
||||
def load(cls, root: Optional[Path] = None) -> "NativeConnectorRegistry":
|
||||
clear_validator_cache()
|
||||
if not native_connectors_v1_enabled():
|
||||
return cls({})
|
||||
directory = root or Path(__file__).resolve().parent / "providers"
|
||||
providers: Dict[str, NativeProvider] = {}
|
||||
if not directory.is_dir():
|
||||
return cls(providers)
|
||||
for child in sorted(path for path in directory.iterdir() if path.is_dir()):
|
||||
if not (child / "manifest.json").is_file():
|
||||
continue
|
||||
if child.name == "didar" and not didar_connector_enabled():
|
||||
continue
|
||||
try:
|
||||
provider = load_provider(child)
|
||||
if provider.id != child.name:
|
||||
raise CatalogError(
|
||||
f"Provider id '{provider.id}' does not match directory '{child.name}'."
|
||||
)
|
||||
validate_provider(provider)
|
||||
except CatalogError as exc:
|
||||
if is_production():
|
||||
logger.critical(
|
||||
"Disabling native provider '%s': %s", child.name, exc
|
||||
)
|
||||
continue
|
||||
raise
|
||||
providers[provider.id] = provider
|
||||
return cls(providers)
|
||||
|
||||
|
||||
def get_registry() -> NativeConnectorRegistry:
|
||||
global _REGISTRY
|
||||
if _REGISTRY is None:
|
||||
_REGISTRY = NativeConnectorRegistry.load()
|
||||
return _REGISTRY
|
||||
|
||||
|
||||
def reset_registry() -> None:
|
||||
global _REGISTRY
|
||||
_REGISTRY = None
|
||||
clear_validator_cache()
|
||||
from kamidso.native_connectors.semantic.catalog import reset_semantic_cache
|
||||
|
||||
reset_semantic_cache()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Resolve attached connector ids into MCP ids and public native instances."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from kamidso.native_connectors.executor import default_row_loader
|
||||
from kamidso.native_connectors.flags import native_connectors_v1_enabled
|
||||
from kamidso.native_connectors.registry import get_registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_public_instance(connector_id: str) -> Optional[Dict[str, Any]]:
|
||||
row = default_row_loader(connector_id)
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
"connector_id": row.get("connector_id"),
|
||||
"connector_type": row.get("connector_type"),
|
||||
"display_name": row.get("display_name") or "",
|
||||
"status": row.get("status") or "",
|
||||
"user_name": row.get("user_name"),
|
||||
"sys_code": row.get("sys_code"),
|
||||
}
|
||||
|
||||
|
||||
def partition_connector_ids(
|
||||
connector_ids: List[str],
|
||||
) -> Tuple[List[str], List[Dict[str, Any]]]:
|
||||
"""Split selected ids. Native ids never go to the MCP tool selector."""
|
||||
if not connector_ids:
|
||||
return [], []
|
||||
if not native_connectors_v1_enabled():
|
||||
return list(connector_ids), []
|
||||
registry = get_registry()
|
||||
mcp_ids: List[str] = []
|
||||
native: List[Dict[str, Any]] = []
|
||||
for connector_id in connector_ids:
|
||||
row = load_public_instance(connector_id)
|
||||
if row is None or not registry.contains(str(row.get("connector_type") or "")):
|
||||
mcp_ids.append(connector_id)
|
||||
continue
|
||||
if str(row.get("status") or "") != "active":
|
||||
logger.warning(
|
||||
"Skipping inactive native connector '%s'", connector_id
|
||||
)
|
||||
continue
|
||||
native.append(
|
||||
{
|
||||
"connector_id": row["connector_id"],
|
||||
"connector_type": row["connector_type"],
|
||||
"display_name": row["display_name"],
|
||||
}
|
||||
)
|
||||
return mcp_ids, native
|
||||
|
||||
|
||||
def install_native_connectors(
|
||||
react_state: Dict[str, Any],
|
||||
instances: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
react_state["native_connectors"] = {
|
||||
str(item["connector_id"]): {
|
||||
"connector_id": item["connector_id"],
|
||||
"connector_type": item["connector_type"],
|
||||
"display_name": item.get("display_name") or item["connector_id"],
|
||||
}
|
||||
for item in instances
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Save raw connector responses where code_interpreter can read them later."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
from kamidso.native_connectors.models import ConnectorFunction, NativeProvider
|
||||
|
||||
_PREVIEW_RECORDS = 10
|
||||
_CELL_CHARS = 120
|
||||
_MAX_OBS_CHARS = 20_000
|
||||
|
||||
|
||||
def get_nested(payload: Any, path: Optional[str]) -> Any:
|
||||
if not path or not isinstance(payload, dict):
|
||||
return None
|
||||
current: Any = payload
|
||||
for part in path.split("."):
|
||||
if not isinstance(current, dict) or part not in current:
|
||||
return None
|
||||
current = current[part]
|
||||
return current
|
||||
|
||||
|
||||
def _json_default(value: Any) -> str:
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
return value.decode("utf-8", errors="replace")
|
||||
return str(value)
|
||||
|
||||
|
||||
def _cell(value: Any) -> str:
|
||||
text = str(value).replace("\n", " ")
|
||||
if len(text) > _CELL_CHARS:
|
||||
return text[:_CELL_CHARS] + "…"
|
||||
return text
|
||||
|
||||
|
||||
def materialize_connector_result(
|
||||
react_state: Dict[str, Any],
|
||||
response_json: Any,
|
||||
*,
|
||||
work_dir: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Write the raw API JSON under ``connector_results/``.
|
||||
|
||||
Returns metadata. The file itself stays the untouched API payload.
|
||||
"""
|
||||
from kamidso.engine.app.openapi.api_v1.tools.sql_query import session_work_dir
|
||||
|
||||
folder_root = work_dir or session_work_dir(react_state.get("conv_id"))
|
||||
out_dir = os.path.join(folder_root, "connector_results")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
seq = int(react_state.get("connector_result_seq") or 0) + 1
|
||||
react_state["connector_result_seq"] = seq
|
||||
name = f"c_{seq:03d}.json"
|
||||
payload = json.dumps(response_json, ensure_ascii=False, default=_json_default)
|
||||
path = os.path.join(out_dir, name)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
with open(os.path.join(out_dir, "latest.json"), "w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
relative = f"connector_results/{name}"
|
||||
react_state["latest_connector_result"] = relative
|
||||
return {
|
||||
"result_id": f"connector_{seq:03d}",
|
||||
"relative_path": relative,
|
||||
"size_bytes": len(payload.encode("utf-8")),
|
||||
}
|
||||
|
||||
|
||||
def _preview_line(index: int, record: Any) -> str:
|
||||
if isinstance(record, dict):
|
||||
preferred = [
|
||||
key
|
||||
for key in ("Title", "Name", "FullName", "Id", "UserId")
|
||||
if key in record
|
||||
]
|
||||
if not preferred:
|
||||
preferred = list(record.keys())[:3]
|
||||
bits = [f"{key}={_cell(record.get(key))}" for key in preferred[:3]]
|
||||
return f"{index}. " + ", ".join(bits)
|
||||
return f"{index}. {_cell(record)}"
|
||||
|
||||
|
||||
def _pagination_lines(
|
||||
function: ConnectorFunction,
|
||||
body: Dict[str, Any],
|
||||
payload: Any,
|
||||
records: Optional[Sequence[Any]],
|
||||
) -> List[str]:
|
||||
pagination = function.pagination or {}
|
||||
if pagination.get("type") != "offset_limit":
|
||||
return []
|
||||
offset_field = str(pagination.get("offset_field") or "From")
|
||||
limit_field = str(pagination.get("limit_field") or "Limit")
|
||||
returned = len(records) if isinstance(records, list) else 0
|
||||
lines = [
|
||||
f"Current page: {offset_field}={body.get(offset_field)} {limit_field}={body.get(limit_field)}",
|
||||
f"Returned: {returned}",
|
||||
]
|
||||
total = get_nested(payload, f"{function.response_root}.TotalCount")
|
||||
if isinstance(total, int):
|
||||
lines.append(f"TotalCount: {total}")
|
||||
offset = body.get(offset_field)
|
||||
if isinstance(offset, int) and offset + returned < total:
|
||||
lines.append("More data exists.")
|
||||
return lines
|
||||
|
||||
|
||||
def render_success_observation(
|
||||
*,
|
||||
provider: NativeProvider,
|
||||
function: ConnectorFunction,
|
||||
body: Dict[str, Any],
|
||||
status_code: int,
|
||||
payload: Any,
|
||||
relative_path: str,
|
||||
) -> str:
|
||||
lines = [
|
||||
f"{provider.display_name} / {function.name} completed successfully.",
|
||||
"",
|
||||
f"HTTP: {status_code}",
|
||||
"",
|
||||
"Summary:",
|
||||
]
|
||||
summaries = False
|
||||
for path in function.summary_fields:
|
||||
value = get_nested(payload, path)
|
||||
if value is None:
|
||||
continue
|
||||
summaries = True
|
||||
label = path.split(".")[-1]
|
||||
lines.append(f"- {label}: {value}")
|
||||
if not summaries:
|
||||
lines.append("- (no summary fields)")
|
||||
if function.records_path and function.records_path.endswith(".List") and function.summary_fields:
|
||||
lines.append(
|
||||
"Response.List is only the current page. "
|
||||
"Use the aggregate counts above for totals."
|
||||
)
|
||||
|
||||
records = get_nested(payload, function.records_path) if function.records_path else None
|
||||
if isinstance(records, list):
|
||||
lines.extend(["", f"Records in current page: {len(records)}", "", "Preview:"])
|
||||
if not records:
|
||||
lines.append("(empty)")
|
||||
for index, record in enumerate(records[:_PREVIEW_RECORDS], start=1):
|
||||
lines.append(_preview_line(index, record))
|
||||
if len(records) > _PREVIEW_RECORDS:
|
||||
lines.append(f"(Showing first {_PREVIEW_RECORDS} of {len(records)} records.)")
|
||||
else:
|
||||
root = get_nested(payload, function.response_root)
|
||||
preview = json.dumps(root if root is not None else payload, ensure_ascii=False, default=_json_default)
|
||||
if len(preview) > 1000:
|
||||
preview = preview[:1000] + "…"
|
||||
lines.extend(["", "Object preview:", preview])
|
||||
|
||||
page_lines = _pagination_lines(function, body, payload, records if isinstance(records, list) else None)
|
||||
if page_lines:
|
||||
lines.extend([""] + page_lines)
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
f"[Saved full result → PLOT_DIR/{relative_path}]",
|
||||
"",
|
||||
"Use code_interpreter only if computation, dataframe operations, joining, "
|
||||
"statistics, charts or export are needed.",
|
||||
]
|
||||
)
|
||||
text = "\n".join(lines)
|
||||
if len(text) > _MAX_OBS_CHARS:
|
||||
text = (
|
||||
text[:_MAX_OBS_CHARS]
|
||||
+ f"\n\n... [Output truncated at {_MAX_OBS_CHARS} chars. "
|
||||
f"Full result at PLOT_DIR/{relative_path}]"
|
||||
)
|
||||
return text
|
||||
Whitespace-only changes.
@@ -0,0 +1,12 @@
|
||||
"""Public metadata for native connectors attached to the current turn.
|
||||
|
||||
Credentials are never stored on react_state. The executor decrypts them
|
||||
only while performing a connector_call.
|
||||
"""
|
||||
|
||||
from kamidso.native_connectors.resolve import (
|
||||
install_native_connectors,
|
||||
partition_connector_ids,
|
||||
)
|
||||
|
||||
__all__ = ["install_native_connectors", "partition_connector_ids"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Didar semantic accuracy layer. Same connector_call tool, no second runtime."""
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Search Deal totals come from response aggregates, never from the current page length."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from kamidso.native_connectors.result_store import get_nested
|
||||
from kamidso.native_connectors.semantic.text import normalize_text
|
||||
|
||||
|
||||
def as_int(value: Any) -> Optional[int]:
|
||||
if isinstance(value, bool) or value is None:
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, str) and value.strip().isdigit():
|
||||
return int(value.strip())
|
||||
return None
|
||||
|
||||
|
||||
def aggregate_count(payload: Any, source: str) -> Optional[int]:
|
||||
return as_int(get_nested(payload, source))
|
||||
|
||||
|
||||
def count_for_question(payload: Any, question: str) -> Tuple[str, Optional[int]]:
|
||||
text = normalize_text(question)
|
||||
if "ناموفق" in text:
|
||||
source = "Response.LostDealsCount"
|
||||
elif "جاری" in text:
|
||||
source = "Response.PendingDealsCount"
|
||||
elif "موفق" in text:
|
||||
source = "Response.WonDealsCount"
|
||||
else:
|
||||
source = "Response.TotalCount"
|
||||
return source, aggregate_count(payload, source)
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Load Didar semantic contracts shipped beside the native provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from kamidso.native_connectors.errors import CatalogError
|
||||
from kamidso.native_connectors.flags import (
|
||||
didar_connector_enabled,
|
||||
native_connectors_v1_enabled,
|
||||
)
|
||||
from kamidso.native_connectors.semantic.text import normalize_text
|
||||
|
||||
_CACHE: Optional[Dict[str, Any]] = None
|
||||
|
||||
_BASIS_DEFAULTS = {
|
||||
"sales_by_owner": "won_at",
|
||||
"sales_over_time": "won_at",
|
||||
}
|
||||
|
||||
_DISCLOSURE = {
|
||||
"sales_summary": "مبنای بازه: تاریخ موفقشدن",
|
||||
"won_deals": "مبنای بازه: تاریخ موفقشدن",
|
||||
"sales_by_owner": "مبنای بازه: تاریخ موفقشدن",
|
||||
"sales_over_time": "مبنای بازه: تاریخ موفقشدن",
|
||||
"lost_deals": "مبنای بازه: تاریخ ناموفقشدن",
|
||||
"deals_created": "مبنای بازه: تاریخ ثبت",
|
||||
"pending_pipeline_snapshot": "مبنای گزارش: وضعیت فعلی",
|
||||
}
|
||||
|
||||
|
||||
def provider_dir() -> Path:
|
||||
return Path(__file__).resolve().parents[1] / "providers" / "didar"
|
||||
|
||||
|
||||
def reset_semantic_cache() -> None:
|
||||
global _CACHE
|
||||
_CACHE = None
|
||||
|
||||
|
||||
def _read(path: Path) -> Dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise CatalogError(f"Cannot read {path.name}: {exc}") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise CatalogError(f"{path.name} must be an object.")
|
||||
return data
|
||||
|
||||
|
||||
def load_bundle() -> Dict[str, Any]:
|
||||
global _CACHE
|
||||
if _CACHE is not None:
|
||||
return _CACHE
|
||||
directory = provider_dir()
|
||||
loaded = {
|
||||
"reports": _read(directory / "reports.json"),
|
||||
"dates": _read(directory / "date_rules.json"),
|
||||
"money": _read(directory / "money_rules.json"),
|
||||
"semantics": _read(directory / "semantics.json"),
|
||||
"enums": _read(directory / "enums.json"),
|
||||
}
|
||||
_CACHE = loaded
|
||||
return loaded
|
||||
|
||||
|
||||
def operations() -> List[Dict[str, Any]]:
|
||||
raw = load_bundle()["reports"].get("semantic_operations") or []
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [item for item in raw if isinstance(item, dict) and item.get("name")]
|
||||
|
||||
|
||||
def _didar_ready(connector_type: str) -> bool:
|
||||
return (
|
||||
connector_type == "didar"
|
||||
and native_connectors_v1_enabled()
|
||||
and didar_connector_enabled()
|
||||
)
|
||||
|
||||
|
||||
def find_operation(connector_type: str, name: str) -> Optional[Dict[str, Any]]:
|
||||
if not _didar_ready(connector_type):
|
||||
return None
|
||||
for operation in operations():
|
||||
if operation.get("name") == name:
|
||||
return operation
|
||||
return None
|
||||
|
||||
|
||||
def enabled_operations(connector_type: str) -> List[Dict[str, Any]]:
|
||||
if not _didar_ready(connector_type):
|
||||
return []
|
||||
return [operation for operation in operations() if operation.get("enabled")]
|
||||
|
||||
|
||||
def operation_enabled(name: str) -> bool:
|
||||
for operation in operations():
|
||||
if operation.get("name") == name:
|
||||
return bool(operation.get("enabled"))
|
||||
return False
|
||||
|
||||
|
||||
def date_basis_for(name: str) -> str:
|
||||
for operation in operations():
|
||||
if operation.get("name") == name and operation.get("date_basis"):
|
||||
return str(operation["date_basis"])
|
||||
return _BASIS_DEFAULTS.get(name, "")
|
||||
|
||||
|
||||
def disclosure_for(name: str) -> str:
|
||||
return _DISCLOSURE.get(name, "")
|
||||
|
||||
|
||||
def verification_cap() -> int:
|
||||
try:
|
||||
value = load_bundle()["dates"]["didar_search_deals_strategy"]["won_at"]["fallback"][
|
||||
"max_records"
|
||||
]
|
||||
return int(value)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return 5000
|
||||
|
||||
|
||||
def _month_names() -> List[str]:
|
||||
months = load_bundle()["dates"].get("jalali_months") or {}
|
||||
if not isinstance(months, dict):
|
||||
return []
|
||||
return [str(name) for name in months]
|
||||
|
||||
|
||||
def _relative_periods() -> List[str]:
|
||||
parser = load_bundle()["dates"].get("parser_requirements") or {}
|
||||
raw = parser.get("accept_relative_periods") if isinstance(parser, dict) else []
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [normalize_text(str(item)) for item in raw]
|
||||
|
||||
|
||||
def has_period(text: str) -> bool:
|
||||
normalized = normalize_text(text)
|
||||
for phrase in _relative_periods():
|
||||
if phrase and phrase in normalized:
|
||||
return True
|
||||
tokens = set(normalized.split())
|
||||
return any(month in tokens for month in _month_names())
|
||||
|
||||
|
||||
def route_question(text: str) -> Optional[str]:
|
||||
"""Map a management question to a semantic operation name."""
|
||||
normalized = normalize_text(text)
|
||||
if "لید" in normalized or "سرنخ" in normalized:
|
||||
return "lead_count"
|
||||
if "محصول" in normalized:
|
||||
return "sales_by_product"
|
||||
if "کارشناس" in normalized or "به تفکیک مالک" in normalized:
|
||||
return "sales_by_owner"
|
||||
if "روند" in normalized:
|
||||
return "sales_over_time"
|
||||
if "جاری" in normalized:
|
||||
return "pending_pipeline_snapshot"
|
||||
if "ناموفق" in normalized:
|
||||
return "lost_deals"
|
||||
if "موفق" in normalized:
|
||||
if has_period(normalized):
|
||||
return "won_deals"
|
||||
return None
|
||||
if "فروش" in normalized:
|
||||
return "sales_summary"
|
||||
if "معاملات" in normalized or "معامله" in normalized:
|
||||
return "deals_created"
|
||||
return None
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Resolve Jalali and relative business periods to a half-open UTC range."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import jdatetime
|
||||
|
||||
from kamidso.native_connectors.semantic.catalog import load_bundle
|
||||
from kamidso.native_connectors.semantic.text import normalize_text
|
||||
|
||||
_TEHRAN = ZoneInfo("Asia/Tehran")
|
||||
|
||||
|
||||
class PeriodError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedPeriod:
|
||||
input_text: str
|
||||
calendar: str
|
||||
timezone: str
|
||||
start_local: str
|
||||
end_local_exclusive: str
|
||||
start_utc: str
|
||||
end_utc_exclusive: str
|
||||
start: datetime
|
||||
end: datetime
|
||||
|
||||
def contains(self, moment: datetime) -> bool:
|
||||
return self.start <= moment < self.end
|
||||
|
||||
|
||||
def parse_utc(value: object) -> Optional[datetime]:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
text = value.strip().replace("Z", "+00:00")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _months() -> dict:
|
||||
raw = load_bundle()["dates"].get("jalali_months") or {}
|
||||
return raw if isinstance(raw, dict) else {}
|
||||
|
||||
|
||||
def _relatives() -> list:
|
||||
parser = load_bundle()["dates"].get("parser_requirements") or {}
|
||||
raw = parser.get("accept_relative_periods") if isinstance(parser, dict) else []
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [normalize_text(str(item)) for item in raw]
|
||||
|
||||
|
||||
def _as_tehran(now: Optional[datetime]) -> datetime:
|
||||
if now is None:
|
||||
return datetime.now(_TEHRAN)
|
||||
if now.tzinfo is None:
|
||||
return now.replace(tzinfo=_TEHRAN)
|
||||
return now.astimezone(_TEHRAN)
|
||||
|
||||
|
||||
def _jalali_today(now: datetime) -> jdatetime.date:
|
||||
return jdatetime.date.fromgregorian(date=now.date())
|
||||
|
||||
|
||||
def _add_months(year: int, month: int, delta: int) -> tuple:
|
||||
index = year * 12 + (month - 1) + delta
|
||||
return index // 12, index % 12 + 1
|
||||
|
||||
|
||||
def _month_bounds(year: int, month: int) -> tuple:
|
||||
start = jdatetime.date(year, month, 1)
|
||||
if month == 12:
|
||||
end = jdatetime.date(year + 1, 1, 1)
|
||||
else:
|
||||
end = jdatetime.date(year, month + 1, 1)
|
||||
return start, end
|
||||
|
||||
|
||||
def _format_offset(moment: datetime) -> str:
|
||||
text = moment.strftime("%z")
|
||||
if len(text) == 5:
|
||||
return text[:3] + ":" + text[3:]
|
||||
return text
|
||||
|
||||
|
||||
def _from_jalali(start: jdatetime.date, end: jdatetime.date, text: str) -> ResolvedPeriod:
|
||||
start_greg = start.togregorian()
|
||||
end_greg = end.togregorian()
|
||||
start_local = datetime(start_greg.year, start_greg.month, start_greg.day, tzinfo=_TEHRAN)
|
||||
end_local = datetime(end_greg.year, end_greg.month, end_greg.day, tzinfo=_TEHRAN)
|
||||
start_utc = start_local.astimezone(timezone.utc)
|
||||
end_utc = end_local.astimezone(timezone.utc)
|
||||
return ResolvedPeriod(
|
||||
input_text=text,
|
||||
calendar="jalali",
|
||||
timezone="Asia/Tehran",
|
||||
start_local=(
|
||||
f"{start.year:04d}-{start.month:02d}-{start.day:02d}T00:00:00"
|
||||
f"{_format_offset(start_local)}"
|
||||
),
|
||||
end_local_exclusive=(
|
||||
f"{end.year:04d}-{end.month:02d}-{end.day:02d}T00:00:00"
|
||||
f"{_format_offset(end_local)}"
|
||||
),
|
||||
start_utc=start_utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
end_utc_exclusive=end_utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
start=start_utc,
|
||||
end=end_utc,
|
||||
)
|
||||
|
||||
|
||||
def _from_tehran_datetimes(start_local: datetime, end_local: datetime, text: str) -> ResolvedPeriod:
|
||||
start_j = jdatetime.date.fromgregorian(date=start_local.date())
|
||||
end_j = jdatetime.date.fromgregorian(date=end_local.date())
|
||||
start_utc = start_local.astimezone(timezone.utc)
|
||||
end_utc = end_local.astimezone(timezone.utc)
|
||||
return ResolvedPeriod(
|
||||
input_text=text,
|
||||
calendar="jalali",
|
||||
timezone="Asia/Tehran",
|
||||
start_local=(
|
||||
f"{start_j.year:04d}-{start_j.month:02d}-{start_j.day:02d}"
|
||||
f"T{start_local.strftime('%H:%M:%S')}{_format_offset(start_local)}"
|
||||
),
|
||||
end_local_exclusive=(
|
||||
f"{end_j.year:04d}-{end_j.month:02d}-{end_j.day:02d}"
|
||||
f"T{end_local.strftime('%H:%M:%S')}{_format_offset(end_local)}"
|
||||
),
|
||||
start_utc=start_utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
end_utc_exclusive=end_utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
start=start_utc,
|
||||
end=end_utc,
|
||||
)
|
||||
|
||||
|
||||
def _relative(normalized: str, now: datetime, original: str) -> Optional[ResolvedPeriod]:
|
||||
today = datetime(now.year, now.month, now.day, tzinfo=_TEHRAN)
|
||||
jalali_today = _jalali_today(now)
|
||||
if normalized == "امروز":
|
||||
return _from_tehran_datetimes(today, today + timedelta(days=1), original)
|
||||
if normalized == "دیروز":
|
||||
return _from_tehran_datetimes(today - timedelta(days=1), today, original)
|
||||
if normalized in {"این هفته", "هفته قبل"}:
|
||||
delta = (today.weekday() - 5) % 7
|
||||
this_saturday = today - timedelta(days=delta)
|
||||
if normalized == "هفته قبل":
|
||||
start = this_saturday - timedelta(days=7)
|
||||
end = this_saturday
|
||||
else:
|
||||
start = this_saturday
|
||||
end = this_saturday + timedelta(days=7)
|
||||
return _from_tehran_datetimes(start, end, original)
|
||||
if normalized == "این ماه":
|
||||
start, end = _month_bounds(jalali_today.year, jalali_today.month)
|
||||
return _from_jalali(start, end, original)
|
||||
if normalized == "ماه قبل":
|
||||
year, month = _add_months(jalali_today.year, jalali_today.month, -1)
|
||||
start, end = _month_bounds(year, month)
|
||||
return _from_jalali(start, end, original)
|
||||
if normalized == "امسال":
|
||||
return _from_jalali(
|
||||
jdatetime.date(jalali_today.year, 1, 1),
|
||||
jdatetime.date(jalali_today.year + 1, 1, 1),
|
||||
original,
|
||||
)
|
||||
if normalized == "سال قبل":
|
||||
return _from_jalali(
|
||||
jdatetime.date(jalali_today.year - 1, 1, 1),
|
||||
jdatetime.date(jalali_today.year, 1, 1),
|
||||
original,
|
||||
)
|
||||
if normalized == "سه ماه اخیر":
|
||||
start_year, start_month = _add_months(jalali_today.year, jalali_today.month, -2)
|
||||
end_year, end_month = _add_months(jalali_today.year, jalali_today.month, 1)
|
||||
return _from_jalali(
|
||||
jdatetime.date(start_year, start_month, 1),
|
||||
jdatetime.date(end_year, end_month, 1),
|
||||
original,
|
||||
)
|
||||
if normalized == "شش ماه اخیر":
|
||||
start_year, start_month = _add_months(jalali_today.year, jalali_today.month, -5)
|
||||
end_year, end_month = _add_months(jalali_today.year, jalali_today.month, 1)
|
||||
return _from_jalali(
|
||||
jdatetime.date(start_year, start_month, 1),
|
||||
jdatetime.date(end_year, end_month, 1),
|
||||
original,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def resolve_period(text: str, *, now: Optional[datetime] = None) -> ResolvedPeriod:
|
||||
original = str(text or "").strip()
|
||||
normalized = normalize_text(original)
|
||||
if not normalized:
|
||||
raise PeriodError("A period is required.")
|
||||
local_now = _as_tehran(now)
|
||||
known = set(_relatives())
|
||||
if normalized in known:
|
||||
resolved = _relative(normalized, local_now, original)
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
months = _months()
|
||||
month_pattern = "|".join(sorted((str(name) for name in months), key=len, reverse=True))
|
||||
if not month_pattern:
|
||||
raise PeriodError(f"Could not resolve period '{original}'.")
|
||||
match = re.fullmatch(
|
||||
rf"(?:ماه\s+)?(?P<month>{month_pattern})(?:\s+ماه)?(?:\s+(?P<year>\d{{4}}))?",
|
||||
normalized,
|
||||
)
|
||||
if match is None:
|
||||
raise PeriodError(
|
||||
f"Could not resolve period '{original}'. "
|
||||
"Use a Jalali month such as مهر ۱۴۰۵ or a relative period such as این ماه."
|
||||
)
|
||||
month = int(months[match.group("month")])
|
||||
year_text = match.group("year")
|
||||
if year_text:
|
||||
year = int(year_text)
|
||||
if year < 1200 or year > 1600:
|
||||
raise PeriodError(f"'{year_text}' is not a Jalali year.")
|
||||
else:
|
||||
year = _jalali_today(local_now).year
|
||||
start, end = _month_bounds(year, month)
|
||||
return _from_jalali(start, end, original)
|
||||
@@ -0,0 +1,782 @@
|
||||
"""Run one semantic operation through the existing raw connector caller."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import jdatetime
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from kamidso.native_connectors.errors import (
|
||||
AccuracyGuard,
|
||||
FunctionDisabled,
|
||||
ResultMaterializationError,
|
||||
)
|
||||
from kamidso.native_connectors.result_store import materialize_connector_result
|
||||
from kamidso.native_connectors.semantic.aggregates import as_int
|
||||
from kamidso.native_connectors.semantic.catalog import (
|
||||
date_basis_for,
|
||||
disclosure_for,
|
||||
verification_cap,
|
||||
)
|
||||
from kamidso.native_connectors.semantic.dates import (
|
||||
PeriodError,
|
||||
ResolvedPeriod,
|
||||
parse_utc,
|
||||
resolve_period,
|
||||
)
|
||||
from kamidso.native_connectors.semantic.money import amount_lines, summarize_amounts
|
||||
from kamidso.native_connectors.validator import _issue, example_skeleton
|
||||
|
||||
_TEHRAN = ZoneInfo("Asia/Tehran")
|
||||
_PAGE = 100
|
||||
_UNTRUSTED = {"won_at", "lost_at"}
|
||||
_GUARD = (
|
||||
"accuracy_guard: this period cannot be fully verified within the row-check limit. "
|
||||
"Narrow the period. No exact total is reported. "
|
||||
"عدد دقیق گزارش نمیشود؛ بازه را کوچکتر کنید."
|
||||
)
|
||||
|
||||
|
||||
def _validation(name: str, schema: Dict[str, Any], body: Any, connector_name: str) -> Optional[Dict[str, Any]]:
|
||||
if not isinstance(body, dict):
|
||||
issues = [{"path": [], "message": "Body must be a JSON object."}]
|
||||
else:
|
||||
validator = Draft202012Validator(schema if isinstance(schema, dict) else {"type": "object"})
|
||||
issues = [
|
||||
_issue(error)
|
||||
for error in sorted(validator.iter_errors(body), key=lambda item: list(item.path))
|
||||
]
|
||||
if not issues:
|
||||
return None
|
||||
return {
|
||||
"ok": False,
|
||||
"error_type": "validation_error",
|
||||
"connector": connector_name,
|
||||
"function": name,
|
||||
"message": "Request body does not match the semantic operation.",
|
||||
"issues": issues,
|
||||
"expected_schema": schema,
|
||||
"example_skeleton": example_skeleton(schema if isinstance(schema, dict) else {}),
|
||||
"http_sent": False,
|
||||
"instruction": (
|
||||
"Fix the body and retry the SAME semantic operation. "
|
||||
"Pass a business body such as a period. Do not build Didar Criteria."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _period_error(name: str, schema: Dict[str, Any], message: str, connector_name: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"error_type": "validation_error",
|
||||
"connector": connector_name,
|
||||
"function": name,
|
||||
"message": message,
|
||||
"issues": [{"path": ["period"], "message": message}],
|
||||
"expected_schema": schema,
|
||||
"example_skeleton": example_skeleton(schema if isinstance(schema, dict) else {}),
|
||||
"http_sent": False,
|
||||
"instruction": "Fix the period and retry the SAME semantic operation.",
|
||||
}
|
||||
|
||||
|
||||
def _basis(operation: Dict[str, Any]) -> str:
|
||||
if operation.get("date_basis"):
|
||||
return str(operation["date_basis"])
|
||||
return date_basis_for(str(operation.get("name") or ""))
|
||||
|
||||
|
||||
def _query(operation: Dict[str, Any], period: Optional[ResolvedPeriod], body: Dict[str, Any], basis: str) -> Dict[str, Any]:
|
||||
template = operation.get("query_template")
|
||||
query: Dict[str, Any]
|
||||
if isinstance(template, dict):
|
||||
query = copy.deepcopy(template)
|
||||
else:
|
||||
query = {"Criteria": {}, "From": 0, "Limit": _PAGE}
|
||||
criteria = query.get("Criteria")
|
||||
if not isinstance(criteria, dict):
|
||||
criteria = {}
|
||||
query["Criteria"] = criteria
|
||||
if operation.get("status"):
|
||||
criteria["Status"] = operation["status"]
|
||||
elif basis == "won_at":
|
||||
criteria["Status"] = "Won"
|
||||
elif basis == "lost_at":
|
||||
criteria["Status"] = "Lost"
|
||||
elif basis == "current_snapshot":
|
||||
criteria["Status"] = "Pending"
|
||||
if operation.get("name") == "deals_created" and body.get("status"):
|
||||
criteria["Status"] = body["status"]
|
||||
if "query_sort" in operation:
|
||||
criteria["Sort"] = operation["query_sort"]
|
||||
elif basis == "won_at":
|
||||
criteria.setdefault("Sort", 9)
|
||||
elif basis == "created_at":
|
||||
criteria.setdefault("Sort", 0)
|
||||
criteria["IsDeleted"] = False
|
||||
if period is not None and basis not in {"current_snapshot", "lost_at"}:
|
||||
criteria["SearchFromTime"] = period.start_utc
|
||||
criteria["SearchToTime"] = period.end_utc_exclusive
|
||||
else:
|
||||
criteria.pop("SearchFromTime", None)
|
||||
criteria.pop("SearchToTime", None)
|
||||
if basis == "lost_at":
|
||||
criteria.pop("Sort", None)
|
||||
if body.get("owner_ids"):
|
||||
criteria["OwnerIds"] = list(body["owner_ids"])
|
||||
if body.get("pipeline_id"):
|
||||
criteria["PipelineId"] = body["pipeline_id"]
|
||||
if body.get("pipeline_stage_ids"):
|
||||
criteria["PipelineStageIds"] = list(body["pipeline_stage_ids"])
|
||||
query["From"] = 0
|
||||
query["Limit"] = _PAGE
|
||||
return query
|
||||
|
||||
|
||||
def _load_saved(react_state: Dict[str, Any], relative_path: str) -> Any:
|
||||
from kamidso.engine.app.openapi.api_v1.tools.sql_query import session_work_dir
|
||||
|
||||
folder = session_work_dir(react_state.get("conv_id"))
|
||||
path = os.path.join(folder, *str(relative_path).split("/"))
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ResultMaterializationError(
|
||||
"The connector response could not be read back for verification.",
|
||||
) from exc
|
||||
|
||||
|
||||
def _deal_rows(payload: Any) -> List[Dict[str, Any]]:
|
||||
response = payload.get("Response") if isinstance(payload, dict) else None
|
||||
if isinstance(response, dict) and isinstance(response.get("List"), list):
|
||||
return [item for item in response["List"] if isinstance(item, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def _metric(payload: Any, key: str) -> Optional[int]:
|
||||
response = payload.get("Response") if isinstance(payload, dict) else None
|
||||
if isinstance(response, dict):
|
||||
return as_int(response.get(key))
|
||||
return None
|
||||
|
||||
|
||||
def _deleted(record: Dict[str, Any]) -> bool:
|
||||
return record.get("IsDeleted") in (True, 1, "1", "true", "True")
|
||||
|
||||
|
||||
def _matches(
|
||||
record: Dict[str, Any],
|
||||
basis: str,
|
||||
period: Optional[ResolvedPeriod],
|
||||
status_filter: Optional[str],
|
||||
) -> bool:
|
||||
if _deleted(record):
|
||||
return False
|
||||
if basis == "current_snapshot":
|
||||
return record.get("Status") == "Pending"
|
||||
if basis == "created_at":
|
||||
if status_filter and record.get("Status") != status_filter:
|
||||
return False
|
||||
if period is None:
|
||||
return False
|
||||
instant = parse_utc(record.get("RegisterTime"))
|
||||
return instant is not None and period.contains(instant)
|
||||
expected = "Won" if basis == "won_at" else "Lost"
|
||||
if record.get("Status") != expected:
|
||||
return False
|
||||
if period is None:
|
||||
return False
|
||||
field = "ChangeToWonTime" if basis == "won_at" else "ChangeToLossTime"
|
||||
instant = parse_utc(record.get(field))
|
||||
return instant is not None and period.contains(instant)
|
||||
|
||||
|
||||
def _calibration(row: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
if not isinstance(row, dict):
|
||||
return {}
|
||||
config = row.get("config")
|
||||
if isinstance(config, str):
|
||||
try:
|
||||
config = json.loads(config)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
if not isinstance(config, dict):
|
||||
return {}
|
||||
stored = config.get("semantic_calibration")
|
||||
return stored if isinstance(stored, dict) else {}
|
||||
|
||||
|
||||
def _bucket(moment: datetime, grain: str) -> str:
|
||||
local = moment.astimezone(_TEHRAN)
|
||||
if grain == "week":
|
||||
start = (local - timedelta(days=(local.weekday() - 5) % 7)).date()
|
||||
stamp = jdatetime.date.fromgregorian(date=start)
|
||||
return f"{stamp.year:04d}-{stamp.month:02d}-{stamp.day:02d}"
|
||||
stamp_dt = jdatetime.datetime.fromgregorian(datetime=local.replace(tzinfo=None))
|
||||
if grain == "day":
|
||||
return f"{stamp_dt.year:04d}-{stamp_dt.month:02d}-{stamp_dt.day:02d}"
|
||||
return f"{stamp_dt.year:04d}-{stamp_dt.month:02d}"
|
||||
|
||||
|
||||
class SemanticExecutor:
|
||||
def __init__(self, runner: Any) -> None:
|
||||
self._runner = runner
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
operation: Dict[str, Any],
|
||||
connector_id: str,
|
||||
body: Optional[Dict[str, Any]],
|
||||
react_state: Dict[str, Any],
|
||||
display_name: str,
|
||||
) -> Dict[str, Any]:
|
||||
name = str(operation.get("name") or "")
|
||||
if not operation.get("enabled"):
|
||||
reason = str(operation.get("reason_disabled") or "This semantic operation is disabled.")
|
||||
raise FunctionDisabled(f"Semantic operation '{name}' is disabled. {reason}", function=name)
|
||||
schema = operation.get("input_schema")
|
||||
if not isinstance(schema, dict):
|
||||
schema = {"type": "object", "additionalProperties": False}
|
||||
payload_body = body or {}
|
||||
invalid = _validation(name, schema, payload_body, display_name)
|
||||
if invalid:
|
||||
return invalid
|
||||
basis = _basis(operation)
|
||||
period: Optional[ResolvedPeriod] = None
|
||||
needs_period = basis != "current_snapshot"
|
||||
if needs_period:
|
||||
try:
|
||||
period = resolve_period(str(payload_body.get("period") or ""))
|
||||
except PeriodError as exc:
|
||||
return _period_error(name, schema, str(exc), display_name)
|
||||
query = _query(operation, period, payload_body, basis)
|
||||
status_filter = payload_body.get("status") if name == "deals_created" else None
|
||||
if not isinstance(status_filter, str):
|
||||
status_filter = None
|
||||
scanned = await self._scan(
|
||||
query=query,
|
||||
connector_id=connector_id,
|
||||
react_state=react_state,
|
||||
display_name=display_name,
|
||||
function_name=str(operation.get("raw_function") or "search_deals"),
|
||||
everything=basis in _UNTRUSTED,
|
||||
)
|
||||
if "rows" not in scanned:
|
||||
return scanned
|
||||
if basis not in _UNTRUSTED:
|
||||
sample = [
|
||||
row
|
||||
for row in scanned["rows"]
|
||||
if _matches(row, basis, period, status_filter)
|
||||
]
|
||||
if len(sample) != len(scanned["rows"]):
|
||||
total = scanned.get("total")
|
||||
if isinstance(total, int) and total > verification_cap():
|
||||
raise AccuracyGuard(_GUARD, function=name)
|
||||
scanned = await self._scan(
|
||||
query=query,
|
||||
connector_id=connector_id,
|
||||
react_state=react_state,
|
||||
display_name=display_name,
|
||||
function_name=str(operation.get("raw_function") or "search_deals"),
|
||||
everything=True,
|
||||
)
|
||||
if "rows" not in scanned:
|
||||
return scanned
|
||||
basis_trusted = False
|
||||
else:
|
||||
basis_trusted = True
|
||||
else:
|
||||
basis_trusted = False
|
||||
matched = [
|
||||
row
|
||||
for row in scanned["rows"]
|
||||
if _matches(row, basis, period, status_filter)
|
||||
]
|
||||
count, count_source, money_rows, money_withheld = _totals(
|
||||
name=name,
|
||||
basis=basis,
|
||||
scanned=scanned,
|
||||
matched=matched,
|
||||
trusted_sample=basis_trusted,
|
||||
)
|
||||
server_count = _server_count(basis, scanned)
|
||||
calibration = _calibration(self._runner.load_row(connector_id))
|
||||
names: Dict[str, str] = {}
|
||||
pipeline_title = ""
|
||||
stage_titles: Dict[str, str] = {}
|
||||
if name == "sales_by_owner":
|
||||
names = await self._users(connector_id, react_state, display_name)
|
||||
if payload_body.get("pipeline_id") or payload_body.get("pipeline_stage_ids"):
|
||||
pipeline_title, stage_titles = await self._pipelines(
|
||||
connector_id,
|
||||
react_state,
|
||||
display_name,
|
||||
str(payload_body.get("pipeline_id") or ""),
|
||||
list(payload_body.get("pipeline_stage_ids") or []),
|
||||
)
|
||||
return self._finish(
|
||||
name=name,
|
||||
basis=basis,
|
||||
period=period,
|
||||
count=count,
|
||||
count_source=count_source,
|
||||
money_rows=money_rows,
|
||||
money_withheld=money_withheld,
|
||||
server_count=server_count,
|
||||
calibration=calibration,
|
||||
matched=matched,
|
||||
names=names,
|
||||
pipeline_title=pipeline_title,
|
||||
stage_titles=stage_titles,
|
||||
grain=str(payload_body.get("grain") or "month"),
|
||||
react_state=react_state,
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
async def _scan(
|
||||
self,
|
||||
*,
|
||||
query: Dict[str, Any],
|
||||
connector_id: str,
|
||||
react_state: Dict[str, Any],
|
||||
display_name: str,
|
||||
function_name: str,
|
||||
everything: bool,
|
||||
) -> Dict[str, Any]:
|
||||
cap = verification_cap()
|
||||
rows: List[Dict[str, Any]] = []
|
||||
snapshot: Dict[str, Optional[int]] = {
|
||||
"total": None,
|
||||
"won": None,
|
||||
"lost": None,
|
||||
"pending": None,
|
||||
}
|
||||
frm = 0
|
||||
while True:
|
||||
page = copy.deepcopy(query)
|
||||
page["From"] = frm
|
||||
page["Limit"] = _PAGE
|
||||
result = await self._runner.execute(
|
||||
connector_id=connector_id,
|
||||
function_name=function_name,
|
||||
body=page,
|
||||
react_state=react_state,
|
||||
display_name=display_name,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
return result
|
||||
payload = _load_saved(react_state, str(result.get("relative_path") or ""))
|
||||
page_rows = _deal_rows(payload)
|
||||
snapshot = {
|
||||
"total": _metric(payload, "TotalCount"),
|
||||
"won": _metric(payload, "WonDealsCount"),
|
||||
"lost": _metric(payload, "LostDealsCount"),
|
||||
"pending": _metric(payload, "PendingDealsCount"),
|
||||
}
|
||||
if everything and isinstance(snapshot["total"], int) and snapshot["total"] > cap:
|
||||
raise AccuracyGuard(_GUARD, function=function_name)
|
||||
rows.extend(page_rows)
|
||||
if isinstance(snapshot["total"], int):
|
||||
complete = not page_rows or frm + len(page_rows) >= snapshot["total"]
|
||||
else:
|
||||
complete = not page_rows or len(page_rows) < _PAGE
|
||||
packed = {"rows": rows, "complete": complete, **snapshot}
|
||||
if not everything or complete:
|
||||
if everything and not rows and isinstance(snapshot["total"], int) and snapshot["total"] > 0:
|
||||
raise AccuracyGuard(_GUARD, function=function_name)
|
||||
if (
|
||||
everything
|
||||
and not rows
|
||||
and snapshot["total"] is None
|
||||
and snapshot["won"] is None
|
||||
and snapshot["lost"] is None
|
||||
):
|
||||
raise AccuracyGuard(_GUARD, function=function_name)
|
||||
return packed
|
||||
if len(rows) >= cap:
|
||||
raise AccuracyGuard(_GUARD, function=function_name)
|
||||
frm += _PAGE
|
||||
|
||||
async def _users(
|
||||
self,
|
||||
connector_id: str,
|
||||
react_state: Dict[str, Any],
|
||||
display_name: str,
|
||||
) -> Dict[str, str]:
|
||||
cache = react_state.setdefault("connector_metadata_cache", {})
|
||||
key = connector_id + ":users"
|
||||
now = time.monotonic()
|
||||
if isinstance(cache, dict):
|
||||
cached = cache.get(key)
|
||||
if isinstance(cached, dict) and now - float(cached.get("at") or 0) < 300:
|
||||
names = cached.get("names")
|
||||
if isinstance(names, dict):
|
||||
return {str(item): str(value) for item, value in names.items()}
|
||||
result = await self._runner.execute(
|
||||
connector_id=connector_id,
|
||||
function_name="get_users",
|
||||
body={},
|
||||
react_state=react_state,
|
||||
display_name=display_name,
|
||||
)
|
||||
names: Dict[str, str] = {}
|
||||
if result.get("ok"):
|
||||
payload = _load_saved(react_state, str(result.get("relative_path") or ""))
|
||||
records = payload.get("Response") if isinstance(payload, dict) else None
|
||||
if isinstance(records, dict):
|
||||
records = records.get("List")
|
||||
if isinstance(records, list):
|
||||
for record in records:
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
user_id = record.get("UserId")
|
||||
label = record.get("DisplayName")
|
||||
if user_id and isinstance(label, str) and label.strip():
|
||||
names[str(user_id)] = label.strip()
|
||||
if isinstance(cache, dict):
|
||||
cache[key] = {"at": now, "names": names}
|
||||
return names
|
||||
|
||||
async def _pipelines(
|
||||
self,
|
||||
connector_id: str,
|
||||
react_state: Dict[str, Any],
|
||||
display_name: str,
|
||||
pipeline_id: str,
|
||||
stage_ids: List[Any],
|
||||
) -> tuple:
|
||||
result = await self._runner.execute(
|
||||
connector_id=connector_id,
|
||||
function_name="get_deal_pipelines",
|
||||
body={},
|
||||
react_state=react_state,
|
||||
display_name=display_name,
|
||||
)
|
||||
titles: Dict[str, str] = {}
|
||||
stages: Dict[str, str] = {}
|
||||
if result.get("ok"):
|
||||
payload = _load_saved(react_state, str(result.get("relative_path") or ""))
|
||||
records = payload.get("Response") if isinstance(payload, dict) else None
|
||||
if isinstance(records, dict):
|
||||
records = records.get("List")
|
||||
if isinstance(records, list):
|
||||
for item in records:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("Id") and item.get("Title"):
|
||||
titles[str(item["Id"])] = str(item["Title"])
|
||||
for stage in item.get("Stages") or []:
|
||||
if isinstance(stage, dict) and stage.get("Id") and stage.get("Title"):
|
||||
stages[str(stage["Id"])] = str(stage["Title"])
|
||||
pipeline_title = titles.get(pipeline_id, "")
|
||||
chosen = {str(item): stages.get(str(item), "") for item in stage_ids}
|
||||
return pipeline_title, chosen
|
||||
|
||||
def _finish(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
basis: str,
|
||||
period: Optional[ResolvedPeriod],
|
||||
count: int,
|
||||
count_source: str,
|
||||
money_rows: List[Dict[str, Any]],
|
||||
money_withheld: bool,
|
||||
server_count: Optional[int],
|
||||
calibration: Dict[str, Any],
|
||||
matched: List[Dict[str, Any]],
|
||||
names: Dict[str, str],
|
||||
pipeline_title: str,
|
||||
stage_titles: Dict[str, str],
|
||||
grain: str,
|
||||
react_state: Dict[str, Any],
|
||||
display_name: str,
|
||||
) -> Dict[str, Any]:
|
||||
money = summarize_amounts(money_rows, calibration)
|
||||
panel_parity = bool(calibration.get("panel_parity_verified"))
|
||||
disagreement = (
|
||||
basis in _UNTRUSTED
|
||||
and server_count is not None
|
||||
and server_count != count
|
||||
)
|
||||
mode = "safe"
|
||||
if (
|
||||
panel_parity
|
||||
and not disagreement
|
||||
and not money_withheld
|
||||
and not money.mixed
|
||||
and (money.currency_verified or not money.groups)
|
||||
):
|
||||
mode = "verified"
|
||||
warnings = _warnings(
|
||||
panel_parity=panel_parity,
|
||||
money=money,
|
||||
money_withheld=money_withheld,
|
||||
disagreement=disagreement,
|
||||
currency_verified=money.currency_verified,
|
||||
)
|
||||
groups = _owner_groups(matched, names) if name == "sales_by_owner" else []
|
||||
timeline = _timeline(matched, grain) if name == "sales_over_time" else []
|
||||
summary = {
|
||||
"semantic_operation": name,
|
||||
"accuracy": {
|
||||
"mode": mode,
|
||||
"date_basis": basis,
|
||||
"period": None
|
||||
if period is None
|
||||
else {
|
||||
"input": period.input_text,
|
||||
"start_utc": period.start_utc,
|
||||
"end_utc_exclusive": period.end_utc_exclusive,
|
||||
},
|
||||
"currency_verified": money.currency_verified,
|
||||
"panel_parity_verified": panel_parity,
|
||||
"warnings": warnings,
|
||||
},
|
||||
"count": count,
|
||||
"count_source": count_source,
|
||||
"amounts": money.groups,
|
||||
"groups": groups,
|
||||
"timeline": timeline,
|
||||
"records": matched,
|
||||
}
|
||||
meta = materialize_connector_result(react_state, summary)
|
||||
observation = _render(
|
||||
display_name=display_name,
|
||||
name=name,
|
||||
mode=mode,
|
||||
basis=basis,
|
||||
period=period,
|
||||
count=count,
|
||||
count_source=count_source,
|
||||
money=money,
|
||||
money_withheld=money_withheld,
|
||||
warnings=warnings,
|
||||
groups=groups,
|
||||
timeline=timeline,
|
||||
pipeline_title=pipeline_title,
|
||||
stage_titles=stage_titles,
|
||||
relative_path=meta["relative_path"],
|
||||
currency_verified=money.currency_verified,
|
||||
panel_parity=panel_parity,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"http_sent": True,
|
||||
"relative_path": meta["relative_path"],
|
||||
"observation": observation,
|
||||
}
|
||||
|
||||
|
||||
def _totals(
|
||||
*,
|
||||
name: str,
|
||||
basis: str,
|
||||
scanned: Dict[str, Any],
|
||||
matched: List[Dict[str, Any]],
|
||||
trusted_sample: bool,
|
||||
) -> tuple:
|
||||
if basis in _UNTRUSTED or not trusted_sample:
|
||||
return len(matched), "verified_rows", matched, False
|
||||
if basis == "current_snapshot":
|
||||
pending = scanned.get("pending")
|
||||
if isinstance(pending, int):
|
||||
return pending, "Response.PendingDealsCount", [], True
|
||||
if scanned.get("complete"):
|
||||
return len(matched), "verified_rows", [], True
|
||||
raise AccuracyGuard(_GUARD, function=name)
|
||||
total = scanned.get("total")
|
||||
if isinstance(total, int):
|
||||
complete = bool(scanned.get("complete"))
|
||||
return total, "Response.TotalCount", matched if complete else [], not complete
|
||||
if scanned.get("complete"):
|
||||
return len(matched), "verified_rows", matched, False
|
||||
raise AccuracyGuard(_GUARD, function=name)
|
||||
|
||||
|
||||
def _server_count(basis: str, scanned: Dict[str, Any]) -> Optional[int]:
|
||||
if basis == "won_at":
|
||||
value = scanned.get("won")
|
||||
elif basis == "lost_at":
|
||||
value = scanned.get("lost")
|
||||
elif basis == "current_snapshot":
|
||||
value = scanned.get("pending")
|
||||
else:
|
||||
value = scanned.get("total")
|
||||
return value if isinstance(value, int) else None
|
||||
|
||||
|
||||
def _warnings(
|
||||
*,
|
||||
panel_parity: bool,
|
||||
money: Any,
|
||||
money_withheld: bool,
|
||||
disagreement: bool,
|
||||
currency_verified: bool,
|
||||
) -> List[str]:
|
||||
warnings: List[str] = []
|
||||
if not panel_parity:
|
||||
warnings.append("Exact parity with the Didar dashboard is not verified for this connection.")
|
||||
if money_withheld:
|
||||
warnings.append(
|
||||
"Amount totals are withheld because the checked page is not the full period."
|
||||
)
|
||||
elif money.mixed:
|
||||
warnings.append("Amounts from different CurrencyId values are not summed.")
|
||||
elif money.groups and not currency_verified:
|
||||
warnings.append("Currency name is not known; amounts are labeled by CurrencyId.")
|
||||
if disagreement:
|
||||
warnings.append(
|
||||
"The server aggregate count was not used because returned rows did not match the period basis."
|
||||
)
|
||||
if money.groups and not money_withheld and not (panel_parity and currency_verified):
|
||||
warnings.append(
|
||||
"FinalAdjustmentPrice is a provisional record amount until golden reconciliation."
|
||||
)
|
||||
return warnings
|
||||
|
||||
|
||||
def _owner_groups(records: List[Dict[str, Any]], names: Dict[str, str]) -> List[Dict[str, Any]]:
|
||||
order: List[str] = []
|
||||
buckets: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for record in records:
|
||||
owner_id = str(record.get("OwnerId") or "")
|
||||
if owner_id not in buckets:
|
||||
order.append(owner_id)
|
||||
buckets[owner_id] = []
|
||||
buckets[owner_id].append(record)
|
||||
groups = []
|
||||
unresolved = 0
|
||||
for owner_id in order:
|
||||
label = names.get(owner_id, "")
|
||||
if not label or label == owner_id:
|
||||
unresolved += 1
|
||||
label = f"کارشناس شناسایینشده {unresolved}"
|
||||
summary = summarize_amounts(buckets[owner_id], {})
|
||||
groups.append(
|
||||
{
|
||||
"owner_id": owner_id,
|
||||
"owner_name": label,
|
||||
"count": len(buckets[owner_id]),
|
||||
"amounts": summary.groups,
|
||||
}
|
||||
)
|
||||
return groups
|
||||
|
||||
|
||||
def _timeline(records: List[Dict[str, Any]], grain: str) -> List[Dict[str, Any]]:
|
||||
order: List[str] = []
|
||||
buckets: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for record in records:
|
||||
instant = parse_utc(record.get("ChangeToWonTime"))
|
||||
if instant is None:
|
||||
continue
|
||||
key = _bucket(instant, grain if grain in {"day", "week", "month"} else "month")
|
||||
if key not in buckets:
|
||||
order.append(key)
|
||||
buckets[key] = []
|
||||
buckets[key].append(record)
|
||||
timeline = []
|
||||
for key in order:
|
||||
summary = summarize_amounts(buckets[key], {})
|
||||
timeline.append(
|
||||
{
|
||||
"bucket": key,
|
||||
"count": len(buckets[key]),
|
||||
"amounts": summary.groups,
|
||||
}
|
||||
)
|
||||
return timeline
|
||||
|
||||
|
||||
def _render(
|
||||
*,
|
||||
display_name: str,
|
||||
name: str,
|
||||
mode: str,
|
||||
basis: str,
|
||||
period: Optional[ResolvedPeriod],
|
||||
count: int,
|
||||
count_source: str,
|
||||
money: Any,
|
||||
money_withheld: bool,
|
||||
warnings: List[str],
|
||||
groups: List[Dict[str, Any]],
|
||||
timeline: List[Dict[str, Any]],
|
||||
pipeline_title: str,
|
||||
stage_titles: Dict[str, str],
|
||||
relative_path: str,
|
||||
currency_verified: bool,
|
||||
panel_parity: bool,
|
||||
) -> str:
|
||||
lines = [
|
||||
f"{display_name} / {name} completed.",
|
||||
"",
|
||||
"Accuracy:",
|
||||
f"- mode: {mode}",
|
||||
f"- date_basis: {basis}",
|
||||
f"- currency_verified: {str(currency_verified).lower()}",
|
||||
f"- panel_parity_verified: {str(panel_parity).lower()}",
|
||||
]
|
||||
disclosure = disclosure_for(name)
|
||||
if disclosure:
|
||||
lines.append(f"- {disclosure}")
|
||||
if period is not None:
|
||||
lines.append(f"- period: {period.input_text}")
|
||||
lines.append(f"- period_utc: [{period.start_utc}, {period.end_utc_exclusive})")
|
||||
if pipeline_title:
|
||||
lines.append(f"- pipeline: {pipeline_title}")
|
||||
elif stage_titles:
|
||||
lines.append("- pipeline: کاریز شناسایینشده")
|
||||
for title in stage_titles.values():
|
||||
lines.append(f"- stage: {title or 'مرحله شناسایینشده'}")
|
||||
lines.extend(["", f"count: {count}", f"count_source: {count_source}"])
|
||||
if money_withheld:
|
||||
lines.append("Amounts: withheld until the full period can be checked.")
|
||||
elif name not in {"sales_by_owner", "sales_over_time"}:
|
||||
lines.extend(amount_lines(money))
|
||||
if groups:
|
||||
lines.extend(["", "By owner:"])
|
||||
for group in groups:
|
||||
parts = [f"{group['owner_name']}: count {group['count']}"]
|
||||
for amount in group["amounts"]:
|
||||
parts.append(f"{amount['label']} { _plain_amount(amount['amount']) }")
|
||||
lines.append("- " + "; ".join(parts))
|
||||
if timeline:
|
||||
lines.extend(["", "Over time:"])
|
||||
for item in timeline:
|
||||
parts = [f"{item['bucket']}: count {item['count']}"]
|
||||
for amount in item["amounts"]:
|
||||
parts.append(f"{amount['label']} { _plain_amount(amount['amount']) }")
|
||||
lines.append("- " + "; ".join(parts))
|
||||
if warnings:
|
||||
lines.extend(["", "warnings:"])
|
||||
for warning in warnings:
|
||||
lines.append(f"- {warning}")
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"Keep these accuracy warnings in the final answer.",
|
||||
f"[Saved semantic result → PLOT_DIR/{relative_path}]",
|
||||
"",
|
||||
"Use code_interpreter only for further computation, joining, charts or export. "
|
||||
"The count above is already verified for this operation.",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _plain_amount(value: float) -> str:
|
||||
if float(value).is_integer():
|
||||
return str(int(value))
|
||||
return str(value)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Golden reconciliation stays unverified until a real panel comparison passes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def reconciliation_verdict(document: Dict[str, Any]) -> Dict[str, Any]:
|
||||
periods = document.get("periods") if isinstance(document, dict) else None
|
||||
if not isinstance(periods, list) or not periods:
|
||||
return {"verified": False, "panel_parity_verified": False, "result": "not_run"}
|
||||
for period in periods:
|
||||
if not isinstance(period, dict) or period.get("result") != "pass":
|
||||
result = "not_run"
|
||||
if isinstance(period, dict) and period.get("result"):
|
||||
result = str(period.get("result"))
|
||||
return {"verified": False, "panel_parity_verified": False, "result": result}
|
||||
expected = period.get("panel_expected")
|
||||
if not isinstance(expected, dict):
|
||||
return {"verified": False, "panel_parity_verified": False, "result": "not_run"}
|
||||
for key, value in expected.items():
|
||||
if key == "sales_by_owner":
|
||||
continue
|
||||
if value is None:
|
||||
return {"verified": False, "panel_parity_verified": False, "result": "not_run"}
|
||||
actual = period.get("kamidso_actual")
|
||||
if not isinstance(actual, dict) or not actual:
|
||||
return {"verified": False, "panel_parity_verified": False, "result": "not_run"}
|
||||
return {"verified": True, "panel_parity_verified": True, "result": "pass"}
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Currency-safe amount reporting. Raw cross-currency sums are never produced."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from kamidso.native_connectors.semantic.catalog import load_bundle
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MoneySummary:
|
||||
groups: List[Dict[str, Any]] = field(default_factory=list)
|
||||
mixed: bool = False
|
||||
combined_total: Optional[float] = None
|
||||
currency_verified: bool = False
|
||||
|
||||
|
||||
def _amount_fields() -> Dict[str, str]:
|
||||
rules = load_bundle()["money"]
|
||||
sources = rules.get("source_fields") if isinstance(rules.get("source_fields"), dict) else {}
|
||||
policy = rules.get("amount_field_policy") if isinstance(rules.get("amount_field_policy"), dict) else {}
|
||||
return {
|
||||
"final": str(policy.get("default_record_amount") or "FinalAdjustmentPrice"),
|
||||
"fallback": str(policy.get("fallback") or "Price"),
|
||||
"currency": str(sources.get("currency_id") or "CurrencyId"),
|
||||
}
|
||||
|
||||
|
||||
def _as_amount(value: Any) -> Optional[float]:
|
||||
if isinstance(value, bool) or value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
text = value.strip().replace(",", "")
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _label(currency_id: str, currency_map: Dict[str, Any]) -> tuple:
|
||||
mapped = currency_map.get(currency_id)
|
||||
if isinstance(mapped, dict):
|
||||
name = mapped.get("code") or mapped.get("name")
|
||||
if name:
|
||||
return str(name), True
|
||||
if isinstance(mapped, str) and mapped.strip():
|
||||
return mapped.strip(), True
|
||||
shown = currency_id or "unknown"
|
||||
return f"CurrencyId={shown}", False
|
||||
|
||||
|
||||
def summarize_amounts(
|
||||
records: List[Any],
|
||||
calibration: Optional[Dict[str, Any]] = None,
|
||||
) -> MoneySummary:
|
||||
calibration = calibration or {}
|
||||
fields = _amount_fields()
|
||||
currency_map = calibration.get("currency_map")
|
||||
if not isinstance(currency_map, dict):
|
||||
currency_map = {}
|
||||
buckets: Dict[str, float] = {}
|
||||
order: List[str] = []
|
||||
for record in records:
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
amount = _as_amount(record.get(fields["final"]))
|
||||
if amount is None:
|
||||
amount = _as_amount(record.get(fields["fallback"]))
|
||||
if amount is None:
|
||||
continue
|
||||
currency = record.get(fields["currency"])
|
||||
key = "" if currency is None else str(currency)
|
||||
if key not in buckets:
|
||||
order.append(key)
|
||||
buckets[key] = 0.0
|
||||
buckets[key] += amount
|
||||
groups: List[Dict[str, Any]] = []
|
||||
for key in order:
|
||||
label, known = _label(key, currency_map)
|
||||
groups.append(
|
||||
{
|
||||
"currency_id": key,
|
||||
"label": label,
|
||||
"amount": buckets[key],
|
||||
"known": known,
|
||||
}
|
||||
)
|
||||
mixed = len(groups) > 1
|
||||
known_single = len(groups) == 1 and bool(groups[0]["known"])
|
||||
verified = bool(calibration.get("dashboard_aggregate_currency_semantics_verified")) and known_single
|
||||
combined = None if mixed or not groups else groups[0]["amount"]
|
||||
return MoneySummary(
|
||||
groups=groups,
|
||||
mixed=mixed,
|
||||
combined_total=combined,
|
||||
currency_verified=verified,
|
||||
)
|
||||
|
||||
|
||||
def format_amount(value: float) -> str:
|
||||
if float(value).is_integer():
|
||||
return str(int(value))
|
||||
return str(value)
|
||||
|
||||
|
||||
def amount_lines(summary: MoneySummary) -> List[str]:
|
||||
if not summary.groups:
|
||||
return ["Amounts: none on the verified rows."]
|
||||
if summary.mixed:
|
||||
lines = ["Amounts by currency (not summed):"]
|
||||
for group in summary.groups:
|
||||
lines.append(f"- {group['label']}: {format_amount(group['amount'])}")
|
||||
lines.append("These currency groups stay separate.")
|
||||
return lines
|
||||
group = summary.groups[0]
|
||||
return [f"Amount: {group['label']} {format_amount(group['amount'])}"]
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Deal status comes only from the Status field."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
_CANONICAL = {
|
||||
"Pending": "pending",
|
||||
"Won": "won",
|
||||
"Lost": "lost",
|
||||
}
|
||||
|
||||
|
||||
def canonical_status(record: Any) -> Optional[str]:
|
||||
if not isinstance(record, dict):
|
||||
return None
|
||||
value = record.get("Status")
|
||||
if isinstance(value, str) and value in _CANONICAL:
|
||||
return _CANONICAL[value]
|
||||
return None
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Shared normalization for Persian business text."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
_DIGITS = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
|
||||
|
||||
|
||||
def normalize_text(value: str) -> str:
|
||||
text = value.replace("\u200c", "").replace("\u200d", "")
|
||||
text = text.replace("ي", "ی").replace("ك", "ک").replace("ة", "ه")
|
||||
text = text.translate(_DIGITS)
|
||||
return " ".join(text.strip().split())
|
||||
@@ -0,0 +1,68 @@
|
||||
"""User-facing connector payloads. Credentials never leave this boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def connector_test_message(result: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Map a probe into a short message safe to show in the product UI."""
|
||||
if result.get("success"):
|
||||
return {"success": True, "message": "اتصال برقرار شد."}
|
||||
status = result.get("status_code")
|
||||
error_type = str(result.get("error_type") or "")
|
||||
if status == 401 or error_type == "authentication_error":
|
||||
message = "کلید API معتبر نیست یا دسترسی آن غیرفعال شده است."
|
||||
elif status == 429 or error_type == "rate_limit_error":
|
||||
message = "محدودیت تعداد درخواستهای API دیدار فعال شده است."
|
||||
elif (isinstance(status, int) and status >= 500) or error_type == "remote_server_error":
|
||||
message = "سرویس دیدار در حال حاضر پاسخ صحیح نمیدهد."
|
||||
else:
|
||||
message = "ارتباط با دیدار برقرار نشد."
|
||||
payload: Dict[str, Any] = {"success": False, "message": message}
|
||||
if isinstance(status, int):
|
||||
payload["status_code"] = status
|
||||
return payload
|
||||
|
||||
|
||||
def public_connector(row: Any) -> Dict[str, Any]:
|
||||
"""Sanitized instance. The stored API key is not a field on this object."""
|
||||
config = row.config if isinstance(getattr(row, "config", None), dict) else {}
|
||||
last_tested = config.get("last_tested_at")
|
||||
last_ok = config.get("last_test_success")
|
||||
return {
|
||||
"id": row.connector_id,
|
||||
"connector_id": row.connector_id,
|
||||
"connector_type": row.connector_type,
|
||||
"connector_kind": "native",
|
||||
"kind": "native",
|
||||
"display_name": row.display_name,
|
||||
"status": row.status,
|
||||
"has_credentials": True,
|
||||
"last_tested_at": last_tested if isinstance(last_tested, str) else None,
|
||||
"last_test_success": last_ok if isinstance(last_ok, bool) else None,
|
||||
}
|
||||
|
||||
|
||||
def public_provider(provider: Any) -> Dict[str, Any]:
|
||||
description = str(getattr(provider, "description", "") or "").strip()
|
||||
if not description:
|
||||
description = (
|
||||
"اتصال مستقیم به مشتریان، معاملات، فعالیتها، کاربران و سایر اطلاعات دیدار."
|
||||
)
|
||||
return {
|
||||
"id": provider.id,
|
||||
"name": provider.display_name,
|
||||
"description": description,
|
||||
"category": provider.category or "crm",
|
||||
"kind": "native",
|
||||
"auth_fields": [
|
||||
{
|
||||
"name": provider.credential_field,
|
||||
"label": "کلید API",
|
||||
"type": "password",
|
||||
"required": True,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""JSON Schema checks and actionable validation payloads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
from jsonschema.exceptions import SchemaError, ValidationError
|
||||
|
||||
from kamidso.native_connectors.errors import CatalogError
|
||||
from kamidso.native_connectors.models import ConnectorFunction
|
||||
|
||||
_COMPILED: Dict[str, Draft202012Validator] = {}
|
||||
|
||||
|
||||
def check_input_schema(function_name: str, schema: Dict[str, Any]) -> None:
|
||||
if not isinstance(schema, dict) or schema.get("type") != "object":
|
||||
raise CatalogError(
|
||||
f"Function '{function_name}' input_schema must be a JSON object schema."
|
||||
)
|
||||
try:
|
||||
Draft202012Validator.check_schema(schema)
|
||||
except SchemaError as exc:
|
||||
raise CatalogError(
|
||||
f"Function '{function_name}' has an invalid input schema: {exc.message}"
|
||||
) from exc
|
||||
|
||||
|
||||
def compiled_validator(function: ConnectorFunction) -> Draft202012Validator:
|
||||
cached = _COMPILED.get(function.name)
|
||||
schema = function.input_schema
|
||||
if cached is not None and cached.schema == schema:
|
||||
return cached
|
||||
validator = Draft202012Validator(schema)
|
||||
_COMPILED[function.name] = validator
|
||||
return validator
|
||||
|
||||
|
||||
def clear_validator_cache() -> None:
|
||||
_COMPILED.clear()
|
||||
|
||||
|
||||
def example_skeleton(schema: Dict[str, Any]) -> Any:
|
||||
if not isinstance(schema, dict):
|
||||
return {}
|
||||
if "default" in schema and schema.get("type") != "object":
|
||||
return schema["default"]
|
||||
kind = schema.get("type")
|
||||
if kind == "object":
|
||||
properties = schema.get("properties") or {}
|
||||
required = schema.get("required") or []
|
||||
skeleton: Dict[str, Any] = {}
|
||||
for key in required:
|
||||
skeleton[key] = example_skeleton(properties.get(key) or {"type": "object"})
|
||||
return skeleton
|
||||
if kind == "array":
|
||||
return []
|
||||
if kind == "integer":
|
||||
minimum = schema.get("minimum", 0)
|
||||
maximum = schema.get("maximum")
|
||||
if isinstance(maximum, int) and maximum <= 100 and isinstance(minimum, int):
|
||||
return maximum
|
||||
return minimum if isinstance(minimum, int) else 0
|
||||
if kind == "number":
|
||||
minimum = schema.get("minimum", 0)
|
||||
return minimum
|
||||
if kind == "boolean":
|
||||
return False
|
||||
if kind == "string":
|
||||
enum = schema.get("enum")
|
||||
if isinstance(enum, list) and enum:
|
||||
return enum[0]
|
||||
return ""
|
||||
return {}
|
||||
|
||||
|
||||
def _issue(error: ValidationError) -> Dict[str, Any]:
|
||||
path = list(error.path)
|
||||
message = error.message
|
||||
if error.validator == "additionalProperties" and isinstance(error.instance, dict):
|
||||
schema = error.schema if isinstance(error.schema, dict) else {}
|
||||
allowed = set((schema.get("properties") or {}).keys())
|
||||
extras = sorted(key for key in error.instance.keys() if key not in allowed)
|
||||
if extras:
|
||||
message = "Unexpected fields: " + ", ".join(extras)
|
||||
return {"path": path, "message": message}
|
||||
|
||||
|
||||
def validate_body(
|
||||
function: ConnectorFunction,
|
||||
body: Any,
|
||||
) -> Tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
"""Return ``(normalized_body, issues)``. Issues empty means the body is valid."""
|
||||
if body is None:
|
||||
body = {}
|
||||
if isinstance(body, str):
|
||||
return None, [{"path": [], "message": "Body must be a JSON object, not a string."}]
|
||||
if not isinstance(body, dict):
|
||||
return None, [{"path": [], "message": "Body must be a JSON object."}]
|
||||
validator = compiled_validator(function)
|
||||
errors = sorted(validator.iter_errors(body), key=lambda item: list(item.path))
|
||||
if not errors:
|
||||
return body, []
|
||||
return None, [_issue(error) for error in errors]
|
||||
|
||||
|
||||
def validation_payload(
|
||||
*,
|
||||
connector_name: str,
|
||||
function: ConnectorFunction,
|
||||
issues: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"error_type": "validation_error",
|
||||
"connector": connector_name,
|
||||
"function": function.name,
|
||||
"message": "Request body does not match the predefined schema.",
|
||||
"issues": issues,
|
||||
"expected_schema": function.input_schema,
|
||||
"example_skeleton": example_skeleton(function.input_schema),
|
||||
"semantic_hints": list(function.semantic_hints),
|
||||
"http_sent": False,
|
||||
"instruction": (
|
||||
"Fix the body and retry the SAME function. Do not invent another function."
|
||||
),
|
||||
}
|
||||
@@ -19,6 +19,8 @@ class ResourceRegistry:
|
||||
doc = await self._store.get_datasource(user.workspace_id, ref.id)
|
||||
elif ref.type == "knowledge":
|
||||
doc = await self._store.get_knowledge_source(user.workspace_id, ref.id)
|
||||
elif ref.type == "connector":
|
||||
doc = _authorize_connector(user, ref.id)
|
||||
else:
|
||||
raise KamidsoError(KamidsoErrorCode.RESOURCE_NOT_FOUND)
|
||||
if doc is None:
|
||||
@@ -52,3 +54,25 @@ class ResourceRegistry:
|
||||
"status": "ready",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _authorize_connector(user: CurrentUser, connector_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Public connector row. Credentials stay in the encrypted connector table."""
|
||||
from kamidso.native_connectors.resolve import load_public_instance
|
||||
|
||||
row = load_public_instance(connector_id)
|
||||
if row is None:
|
||||
return None
|
||||
owner = str(row.get("user_name") or "")
|
||||
if owner and owner != user.id:
|
||||
return None
|
||||
workspace = str(row.get("sys_code") or "")
|
||||
if workspace and user.workspace_id and workspace != user.workspace_id:
|
||||
return None
|
||||
status = str(row.get("status") or "")
|
||||
return {
|
||||
"id": row.get("connector_id"),
|
||||
"status": status,
|
||||
"name": row.get("display_name") or connector_id,
|
||||
"connector_type": row.get("connector_type"),
|
||||
}
|
||||
@@ -140,7 +140,12 @@ class DataLayout:
|
||||
os.environ["HF_HOME"] = str(huggingface)
|
||||
os.environ["HUGGINGFACE_HUB_CACHE"] = str(huggingface / "hub")
|
||||
os.environ["TRANSFORMERS_CACHE"] = str(huggingface / "transformers")
|
||||
os.environ["KAMIDSO_SKIP_SQLITE_FILE"] = "1"
|
||||
# Connector rows live in the engine metadata DB. Keep that file under
|
||||
# data/meta so a process restart does not drop saved connections.
|
||||
os.environ.pop("KAMIDSO_SKIP_SQLITE_FILE", None)
|
||||
os.environ["DBGPT_OBSERVABILITY_SQLITE_PATH"] = str(
|
||||
self.logs / "observability.db"
|
||||
)
|
||||
os.environ["KAMIDSO_OBSERVABILITY_JSONL"] = str(
|
||||
self.logs / "observability.jsonl"
|
||||
)
|
||||
|
||||
@@ -51,6 +51,7 @@ dependencies = [
|
||||
"seaborn",
|
||||
"gTTS==2.3.1",
|
||||
"pymysql",
|
||||
"jdatetime==5.2.0",
|
||||
"jsonschema",
|
||||
"python-jsonpath",
|
||||
"tokenizers>=0.14",
|
||||
|
||||
@@ -45,6 +45,7 @@ coloredlogs
|
||||
seaborn
|
||||
gTTS==2.3.1
|
||||
pymysql
|
||||
jdatetime==5.2.0
|
||||
jsonschema
|
||||
python-jsonpath
|
||||
tokenizers>=0.14
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Product-facing connector payloads stay free of secrets."""
|
||||
|
||||
from kamidso.contracts.user import CurrentUser
|
||||
from kamidso.engine.serve.connector.service.service import ConnectorResponse
|
||||
from kamidso.native_connectors.ux import connector_test_message, public_connector
|
||||
|
||||
|
||||
def test_probe_messages_are_persian_and_drop_the_remote_body():
|
||||
ok = connector_test_message({"success": True, "message": "Connection successful"})
|
||||
assert ok == {"success": True, "message": "اتصال برقرار شد."}
|
||||
|
||||
denied = connector_test_message(
|
||||
{
|
||||
"success": False,
|
||||
"status_code": 401,
|
||||
"error_type": "authentication_error",
|
||||
"message": "apikey=super-secret",
|
||||
}
|
||||
)
|
||||
assert denied["success"] is False
|
||||
assert denied["status_code"] == 401
|
||||
assert "super-secret" not in denied["message"]
|
||||
assert "کلید API" in denied["message"]
|
||||
|
||||
limited = connector_test_message({"success": False, "status_code": 429})
|
||||
assert "محدودیت" in limited["message"]
|
||||
|
||||
remote = connector_test_message({"success": False, "status_code": 503})
|
||||
assert "سرویس دیدار" in remote["message"]
|
||||
|
||||
offline = connector_test_message({"success": False, "error_type": "transport_error"})
|
||||
assert offline["message"] == "ارتباط با دیدار برقرار نشد."
|
||||
assert "status_code" not in offline
|
||||
|
||||
|
||||
def test_public_connector_hides_config_secrets():
|
||||
row = ConnectorResponse(
|
||||
connector_id="conn_1",
|
||||
connector_type="didar",
|
||||
display_name="دیدار فروش",
|
||||
status="active",
|
||||
config={"api_key": "super-secret", "last_tested_at": "2026-10-12T09:00:00Z", "last_test_success": True},
|
||||
)
|
||||
public = public_connector(row)
|
||||
assert public["has_credentials"] is True
|
||||
assert public["connector_kind"] == "native"
|
||||
assert public["last_tested_at"] == "2026-10-12T09:00:00Z"
|
||||
assert "super-secret" not in str(public)
|
||||
assert "api_key" not in public
|
||||
assert "config" not in public
|
||||
|
||||
|
||||
def test_authorize_connector_rejects_another_workspace(monkeypatch):
|
||||
from kamidso.resources.registry import _authorize_connector
|
||||
|
||||
monkeypatch.setattr(
|
||||
"kamidso.native_connectors.resolve.load_public_instance",
|
||||
lambda connector_id: {
|
||||
"connector_id": connector_id,
|
||||
"connector_type": "didar",
|
||||
"display_name": "دیدار فروش",
|
||||
"status": "active",
|
||||
"user_name": "usr_1",
|
||||
"sys_code": "ws_a",
|
||||
},
|
||||
)
|
||||
user = CurrentUser(
|
||||
id="usr_1",
|
||||
email="a@kamidso.test",
|
||||
name="A",
|
||||
role="user",
|
||||
workspace_id="ws_b",
|
||||
)
|
||||
assert _authorize_connector(user, "conn_1") is None
|
||||
@@ -161,3 +161,29 @@ def test_selected_skill_appends_settings_and_rule_pack_instructions():
|
||||
assert "optional hint" in ctx.behavior
|
||||
assert "IR-1405" in ctx.behavior
|
||||
assert "Article 38" in ctx.behavior
|
||||
|
||||
|
||||
def test_connector_only_does_not_claim_nothing_is_attached():
|
||||
ctx = KamidsoContextBuilder().build(
|
||||
_user(),
|
||||
ext_info={
|
||||
"connector_ids": ["didar-a"],
|
||||
"connector_labels": ["دیدار فروش"],
|
||||
},
|
||||
)
|
||||
assert "No file or database is attached this turn" not in ctx.behavior
|
||||
assert "connector_call" in ctx.behavior
|
||||
assert "دیدار فروش" in ctx.behavior
|
||||
assert "validation_error" in ctx.behavior
|
||||
|
||||
|
||||
def test_connector_with_file_keeps_both_sources():
|
||||
ctx = KamidsoContextBuilder().build(
|
||||
_user(),
|
||||
files=["file_1"],
|
||||
ext_info={"connector_ids": ["didar-a"], "connector_labels": ["دیدار فروش"]},
|
||||
)
|
||||
assert "File(s) are attached this turn" in ctx.behavior
|
||||
assert "connector_call" in ctx.behavior
|
||||
assert "code_interpreter" in ctx.behavior
|
||||
|
||||
@@ -77,6 +77,7 @@ def test_bind_process_env_sets_engine_home(tmp_path, monkeypatch):
|
||||
"HUGGINGFACE_HUB_CACHE",
|
||||
"TRANSFORMERS_CACHE",
|
||||
"KAMIDSO_SKIP_SQLITE_FILE",
|
||||
"DBGPT_OBSERVABILITY_SQLITE_PATH",
|
||||
"KAMIDSO_OBSERVABILITY_JSONL",
|
||||
"KAMIDSO_SKILLS_DIR",
|
||||
"DBGPT_SKILLS_DIR",
|
||||
@@ -94,7 +95,11 @@ def test_bind_process_env_sets_engine_home(tmp_path, monkeypatch):
|
||||
assert Path(os.environ["CHROMA_PERSIST_PATH"]) == layout.vectors
|
||||
assert Path(os.environ["TMPDIR"]) == layout.temp
|
||||
assert Path(os.environ["HF_HOME"]) == layout.cache / "huggingface"
|
||||
assert os.environ["KAMIDSO_SKIP_SQLITE_FILE"] == "1"
|
||||
assert "KAMIDSO_SKIP_SQLITE_FILE" not in os.environ
|
||||
assert (
|
||||
Path(os.environ["DBGPT_OBSERVABILITY_SQLITE_PATH"])
|
||||
== layout.logs / "observability.db"
|
||||
)
|
||||
assert Path(engine_paths.LOGDIR) == layout.logs
|
||||
assert Path(engine_paths.PILOT_PATH) == layout.runtime
|
||||
assert Path(os.environ["KAMIDSO_VECTORS_PATH"]) == layout.vectors
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
"""Didar semantic accuracy: status, dates, money, and report routing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
import jdatetime
|
||||
import pytest
|
||||
|
||||
from kamidso.engine.app.openapi.api_v1.tools.connector_call import make_connector_call
|
||||
from kamidso.engine.core.agent.resource.connector.credential import CredentialStore
|
||||
from kamidso.native_connectors.executor import NativeConnectorExecutor
|
||||
from kamidso.native_connectors.prompt import render_native_prompt
|
||||
from kamidso.native_connectors.registry import get_registry, reset_registry
|
||||
from kamidso.native_connectors.semantic.aggregates import count_for_question
|
||||
from kamidso.native_connectors.semantic.catalog import (
|
||||
disclosure_for,
|
||||
find_operation,
|
||||
operation_enabled,
|
||||
provider_dir,
|
||||
route_question,
|
||||
)
|
||||
from kamidso.native_connectors.semantic.dates import resolve_period
|
||||
from kamidso.native_connectors.semantic.golden import reconciliation_verdict
|
||||
from kamidso.native_connectors.semantic.money import summarize_amounts
|
||||
from kamidso.native_connectors.semantic.status import canonical_status
|
||||
|
||||
_TEHRAN = ZoneInfo("Asia/Tehran")
|
||||
|
||||
|
||||
def _enable(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("KAMIDSO_NATIVE_CONNECTORS_V1", "1")
|
||||
monkeypatch.setenv("KAMIDSO_DIDAR_CONNECTOR", "1")
|
||||
monkeypatch.delenv("KAMIDSO_ENV", raising=False)
|
||||
reset_registry()
|
||||
|
||||
|
||||
def _row(store: CredentialStore) -> dict:
|
||||
salt = store.generate_salt()
|
||||
return {
|
||||
"connector_id": "didar-a",
|
||||
"connector_type": "didar",
|
||||
"display_name": "دیدار فروش",
|
||||
"status": "active",
|
||||
"encrypted_credentials": store.encrypt({"api_key": "super-secret-key"}, salt),
|
||||
"encryption_salt": salt,
|
||||
"user_name": "usr_test",
|
||||
"config": {},
|
||||
}
|
||||
|
||||
|
||||
def _client(handler, config=None):
|
||||
store = CredentialStore()
|
||||
row = _row(store)
|
||||
if config is not None:
|
||||
row["config"] = config
|
||||
transport = httpx.MockTransport(handler)
|
||||
client = httpx.AsyncClient(transport=transport)
|
||||
executor = NativeConnectorExecutor(
|
||||
client=client,
|
||||
credential_store=store,
|
||||
row_loader=lambda connector_id: row if connector_id == "didar-a" else None,
|
||||
)
|
||||
return executor, client
|
||||
|
||||
|
||||
def _content(text: str) -> str:
|
||||
payload = json.loads(text)
|
||||
return payload["chunks"][0]["content"]
|
||||
|
||||
|
||||
def _state():
|
||||
return {
|
||||
"conv_id": "conv-semantic",
|
||||
"user_name": "usr_test",
|
||||
"native_connectors": {
|
||||
"didar-a": {
|
||||
"connector_id": "didar-a",
|
||||
"connector_type": "didar",
|
||||
"display_name": "دیدار فروش",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _won(when: str, amount: str, currency: int, owner: str = "owner-a") -> dict:
|
||||
return {
|
||||
"Status": "Won",
|
||||
"ChangeToWonTime": when,
|
||||
"FinalAdjustmentPrice": amount,
|
||||
"CurrencyId": currency,
|
||||
"OwnerId": owner,
|
||||
"IsDeleted": False,
|
||||
}
|
||||
|
||||
|
||||
def test_contract_file_locks_status_counts_and_routing(monkeypatch: pytest.MonkeyPatch):
|
||||
_enable(monkeypatch)
|
||||
document = json.loads(
|
||||
(provider_dir() / "semantic_contract_tests.json").read_text(encoding="utf-8")
|
||||
)
|
||||
for case in document["tests"]:
|
||||
if case["id"] == "deal_status_authoritative":
|
||||
assert canonical_status(case["input_record"]) == case["expected"]["canonical_status"]
|
||||
elif case["id"] == "search_deals_page_count_not_total":
|
||||
source, count = count_for_question(case["response"], case["question"])
|
||||
assert source == case["expected_count_source"]
|
||||
assert count == case["expected_count"]
|
||||
assert count != len(case["response"]["Response"]["List"])
|
||||
elif case["id"] == "sales_period_uses_won_time":
|
||||
name = route_question(case["question"])
|
||||
assert name == case["expected_operation"]
|
||||
operation = find_operation("didar", name)
|
||||
assert operation is not None
|
||||
assert operation["date_basis"] == case["expected_date_basis"]
|
||||
assert operation["query_template"]["Criteria"]["Status"] == case["expected_status"]
|
||||
elif case["id"] == "generic_deals_period_uses_register_time":
|
||||
name = route_question(case["question"])
|
||||
assert name == case["expected_operation"]
|
||||
assert find_operation("didar", name)["date_basis"] == case["expected_date_basis"]
|
||||
assert case["required_disclosure"] in disclosure_for(name)
|
||||
elif case["id"] == "multi_currency_no_raw_sum":
|
||||
summary = summarize_amounts(case["records"], {})
|
||||
assert summary.mixed is True
|
||||
assert summary.combined_total is None
|
||||
assert case["expected"]["raw_cross_currency_sum_allowed"] is False
|
||||
elif case["id"] == "unknown_currency_no_label_guess":
|
||||
summary = summarize_amounts(case["records"], case["calibration"])
|
||||
label = summary.groups[0]["label"]
|
||||
assert label == "CurrencyId=96107"
|
||||
assert "تومان" not in label
|
||||
assert "ریال" not in label
|
||||
assert case["expected"]["may_say_toman"] is False
|
||||
elif case["id"] == "lead_function_not_invented":
|
||||
assert route_question(case["question"]) == "lead_count"
|
||||
assert operation_enabled("lead_count") is False
|
||||
enabled = {item.name for item in get_registry().get("didar").enabled_reads()}
|
||||
for blocked in case["expected"]["must_not_call"]:
|
||||
assert blocked not in enabled
|
||||
|
||||
|
||||
def test_accuracy_patch_adds_sort_without_dropping_the_page_cap(monkeypatch: pytest.MonkeyPatch):
|
||||
_enable(monkeypatch)
|
||||
deals = get_registry().get("didar").function("search_deals")
|
||||
criteria = deals.input_schema["properties"]["Criteria"]["properties"]
|
||||
assert criteria["Sort"]["maximum"] == 10
|
||||
assert criteria["Status"]["enum"] == ["Pending", "Won", "Lost"]
|
||||
assert deals.input_schema["properties"]["Limit"]["maximum"] == 100
|
||||
assert "Response.WonDealsTotalPrice" in deals.summary_fields
|
||||
assert any("Sort=9" in hint for hint in deals.semantic_hints)
|
||||
users = get_registry().get("didar").function("get_users")
|
||||
assert users.records_path == "Response"
|
||||
|
||||
|
||||
def test_jalali_month_is_half_open_tehran_and_handles_esfand():
|
||||
mehr = resolve_period("مهر ۱۴۰۵")
|
||||
same = resolve_period("مهر 1405")
|
||||
assert mehr.start_utc == same.start_utc == "2026-09-22T20:30:00Z"
|
||||
assert mehr.end_utc_exclusive == "2026-10-22T20:30:00Z"
|
||||
assert mehr.start_local.startswith("1405-07-01T00:00:00")
|
||||
assert mehr.end_local_exclusive.startswith("1405-08-01T00:00:00")
|
||||
assert mehr.contains(mehr.start) is True
|
||||
assert mehr.contains(mehr.end) is False
|
||||
assert mehr.contains(mehr.end - timedelta(seconds=1)) is True
|
||||
|
||||
esfand = resolve_period("اسفند ۱۴۰۳")
|
||||
assert esfand.start_local.startswith("1403-12-01")
|
||||
assert esfand.end_local_exclusive.startswith("1404-01-01")
|
||||
jdatetime.date(1403, 12, 30)
|
||||
with pytest.raises(ValueError):
|
||||
jdatetime.date(1404, 12, 30)
|
||||
|
||||
now = datetime(2026, 10, 11, 12, 0, tzinfo=_TEHRAN)
|
||||
omitted = resolve_period("مهر", now=now)
|
||||
assert omitted.start_utc == mehr.start_utc
|
||||
month = resolve_period("این ماه", now=now)
|
||||
today = jdatetime.date.fromgregorian(date=now.date())
|
||||
assert month.start_local.startswith(f"{today.year:04d}-{today.month:02d}-01")
|
||||
week = resolve_period("این هفته", now=now)
|
||||
start = datetime.fromisoformat(week.start_utc.replace("Z", "+00:00")).astimezone(_TEHRAN)
|
||||
assert start.weekday() == 5
|
||||
assert start.hour == 0
|
||||
assert week.end - week.start == timedelta(days=7)
|
||||
|
||||
|
||||
def test_shipped_golden_template_is_not_verified():
|
||||
document = json.loads(
|
||||
(provider_dir() / "golden_reconciliation_template.json").read_text(encoding="utf-8")
|
||||
)
|
||||
verdict = reconciliation_verdict(document)
|
||||
assert verdict["verified"] is False
|
||||
assert verdict["panel_parity_verified"] is False
|
||||
passed = reconciliation_verdict(
|
||||
{
|
||||
"periods": [
|
||||
{
|
||||
"result": "pass",
|
||||
"panel_expected": {
|
||||
"sales_won_count": 1,
|
||||
"sales_amount": 1,
|
||||
"currency_display": "IRR",
|
||||
"won_count": 1,
|
||||
"lost_count": 0,
|
||||
"pending_count_snapshot": 0,
|
||||
"sales_by_owner": [],
|
||||
},
|
||||
"kamidso_actual": {"sales_won_count": 1},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
assert passed["verified"] is True
|
||||
|
||||
|
||||
def test_prompt_prefers_semantic_operations(monkeypatch: pytest.MonkeyPatch):
|
||||
_enable(monkeypatch)
|
||||
text = render_native_prompt(
|
||||
[{"connector_id": "didar-a", "connector_type": "didar", "display_name": "دیدار فروش"}]
|
||||
)
|
||||
assert "sales_summary" in text
|
||||
assert "deals_created" in text
|
||||
assert "get_leads" in text
|
||||
assert "additionalProperties" not in text
|
||||
assert "/api/" not in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sales_summary_verifies_won_time_and_refuses_cross_currency_sum(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
):
|
||||
_enable(monkeypatch)
|
||||
monkeypatch.setattr("kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path))
|
||||
period = resolve_period("مهر ۱۴۰۵")
|
||||
calls = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(json.loads(request.content.decode()))
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"Response": {
|
||||
"TotalCount": 2,
|
||||
"WonDealsCount": 2,
|
||||
"List": [
|
||||
_won(period.start_utc, "100", 1),
|
||||
_won(period.start_utc, "200", 2),
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
executor, client = _client(handler)
|
||||
try:
|
||||
tool = make_connector_call(_state(), executor=executor)
|
||||
text = _content(await tool(name="sales_summary", body={"period": "مهر ۱۴۰۵"}))
|
||||
finally:
|
||||
await client.aclose()
|
||||
assert len(calls) == 1
|
||||
criteria = calls[0]["Criteria"]
|
||||
assert criteria["Status"] == "Won"
|
||||
assert criteria["Sort"] == 9
|
||||
assert criteria["SearchFromTime"] == period.start_utc
|
||||
assert criteria["SearchToTime"] == period.end_utc_exclusive
|
||||
assert criteria["IsDeleted"] is False
|
||||
assert "From" not in criteria
|
||||
assert "\ncount: 2\n" in text
|
||||
assert "date_basis: won_at" in text
|
||||
assert "مبنای بازه: تاریخ موفقشدن" in text
|
||||
assert "CurrencyId=1" in text
|
||||
assert "CurrencyId=2" in text
|
||||
assert "300" not in text
|
||||
assert "تومان" not in text
|
||||
assert "ریال" not in text
|
||||
assert "panel_parity_verified: false" in text
|
||||
assert "mode: safe" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_timestamp_does_not_count_as_won(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
):
|
||||
_enable(monkeypatch)
|
||||
monkeypatch.setattr("kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path))
|
||||
period = resolve_period("مهر 1405")
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"Response": {
|
||||
"TotalCount": 1,
|
||||
"WonDealsCount": 1,
|
||||
"List": [
|
||||
{
|
||||
"Status": "Pending",
|
||||
"ChangeToWonTime": period.start_utc,
|
||||
"Probability": 100,
|
||||
"FinalAdjustmentPrice": "40",
|
||||
"CurrencyId": 96107,
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
executor, client = _client(handler)
|
||||
try:
|
||||
tool = make_connector_call(_state(), executor=executor)
|
||||
text = _content(await tool(name="sales_summary", body={"period": "مهر 1405"}))
|
||||
finally:
|
||||
await client.aclose()
|
||||
assert "\ncount: 0\n" in text
|
||||
assert "\ncount: 1\n" not in text
|
||||
assert "was not used" in text
|
||||
assert "تومان" not in text
|
||||
assert "ریال" not in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deals_created_uses_registration_total_not_the_page(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
):
|
||||
_enable(monkeypatch)
|
||||
monkeypatch.setattr("kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path))
|
||||
period = resolve_period("مهر ۱۴۰۵")
|
||||
calls = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(json.loads(request.content.decode()))
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"Response": {
|
||||
"TotalCount": 12,
|
||||
"List": [
|
||||
{
|
||||
"Status": "Pending",
|
||||
"ChangeToWonTime": period.start_utc,
|
||||
"RegisterTime": period.start_utc,
|
||||
"CurrencyId": 96107,
|
||||
"FinalAdjustmentPrice": "10",
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
executor, client = _client(handler)
|
||||
try:
|
||||
tool = make_connector_call(_state(), executor=executor)
|
||||
text = _content(await tool(name="deals_created", body={"period": "مهر ۱۴۰۵"}))
|
||||
finally:
|
||||
await client.aclose()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["Criteria"]["Sort"] == 0
|
||||
assert "Status" not in calls[0]["Criteria"]
|
||||
assert "\ncount: 12\n" in text
|
||||
assert "count_source: Response.TotalCount" in text
|
||||
assert "مبنای بازه: تاریخ ثبت" in text
|
||||
assert "date_basis: created_at" in text
|
||||
assert "Amounts: withheld" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sales_summary_pages_past_the_first_hundred(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
):
|
||||
_enable(monkeypatch)
|
||||
monkeypatch.setattr("kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path))
|
||||
period = resolve_period("مهر 1405")
|
||||
offsets = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(request.content.decode())
|
||||
offsets.append(body["From"])
|
||||
if body["From"] == 0:
|
||||
rows = [_won(period.start_utc, "1", 1) for _ in range(100)]
|
||||
else:
|
||||
rows = [_won(period.start_utc, "1", 1)]
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"Response": {"TotalCount": 101, "WonDealsCount": 101, "List": rows}},
|
||||
)
|
||||
|
||||
executor, client = _client(handler)
|
||||
try:
|
||||
tool = make_connector_call(_state(), executor=executor)
|
||||
text = _content(await tool(name="sales_summary", body={"period": "مهر 1405"}))
|
||||
finally:
|
||||
await client.aclose()
|
||||
assert offsets == [0, 100]
|
||||
assert "\ncount: 101\n" in text
|
||||
assert "\ncount: 100\n" not in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_over_cap_period_returns_accuracy_guard_without_a_total(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
):
|
||||
_enable(monkeypatch)
|
||||
monkeypatch.setattr("kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path))
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls["n"] += 1
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"Response": {
|
||||
"TotalCount": 5001,
|
||||
"WonDealsCount": 5001,
|
||||
"List": [_won("2026-09-23T00:00:00Z", "9", 1)],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
executor, client = _client(handler)
|
||||
try:
|
||||
tool = make_connector_call(_state(), executor=executor)
|
||||
text = await tool(name="sales_summary", body={"period": "مهر ۱۴۰۵"})
|
||||
finally:
|
||||
await client.aclose()
|
||||
assert calls["n"] == 1
|
||||
assert "accuracy_guard" in text
|
||||
assert "5001" not in text
|
||||
assert "count:" not in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_lead_count_and_malformed_body_send_no_http(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
):
|
||||
_enable(monkeypatch)
|
||||
monkeypatch.setattr("kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path))
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls["n"] += 1
|
||||
return httpx.Response(200, json={"Response": []})
|
||||
|
||||
executor, client = _client(handler)
|
||||
try:
|
||||
tool = make_connector_call(_state(), executor=executor)
|
||||
disabled = await tool(name="lead_count", body={"period": "مهر ۱۴۰۵"})
|
||||
invalid = await tool(name="sales_summary", body={"Criteria": {"Status": "Won"}})
|
||||
unknown = await tool(name="get_leads", body={})
|
||||
finally:
|
||||
await client.aclose()
|
||||
assert calls["n"] == 0
|
||||
assert "function_disabled" in disabled
|
||||
assert "validation_error" in invalid
|
||||
assert "http_sent" in invalid
|
||||
assert "unknown_function" in unknown
|
||||
assert "search_persons" in unknown
|
||||
assert "search_deals" in unknown
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sales_by_owner_resolves_names_and_hides_raw_ids(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
):
|
||||
_enable(monkeypatch)
|
||||
monkeypatch.setattr("kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path))
|
||||
period = resolve_period("مهر 1405")
|
||||
owner_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/User/List"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"Response": [{"UserId": owner_id, "DisplayName": "سارا"}]},
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"Response": {
|
||||
"TotalCount": 1,
|
||||
"WonDealsCount": 1,
|
||||
"List": [_won(period.start_utc, "80", 96107, owner_id)],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
executor, client = _client(handler)
|
||||
try:
|
||||
tool = make_connector_call(_state(), executor=executor)
|
||||
text = _content(await tool(name="sales_by_owner", body={"period": "مهر 1405"}))
|
||||
finally:
|
||||
await client.aclose()
|
||||
assert "سارا" in text
|
||||
assert owner_id not in text
|
||||
assert "CurrencyId=96107" in text
|
||||
assert "تومان" not in text
|
||||
assert "ریال" not in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calibrated_account_can_be_marked_verified(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
):
|
||||
_enable(monkeypatch)
|
||||
monkeypatch.setattr("kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path))
|
||||
period = resolve_period("مهر 1405")
|
||||
pipeline_id = "11111111-2222-3333-4444-555555555555"
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/pipeline/list/0"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"Response": [{"Id": pipeline_id, "Title": "فروش سازمانی", "Stages": []}]},
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"Response": {
|
||||
"TotalCount": 1,
|
||||
"WonDealsCount": 1,
|
||||
"List": [_won(period.start_utc, "80", 1)],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
executor, client = _client(
|
||||
handler,
|
||||
{
|
||||
"semantic_calibration": {
|
||||
"panel_parity_verified": True,
|
||||
"dashboard_aggregate_currency_semantics_verified": True,
|
||||
"currency_map": {"1": {"code": "IRR"}},
|
||||
}
|
||||
},
|
||||
)
|
||||
try:
|
||||
tool = make_connector_call(_state(), executor=executor)
|
||||
text = _content(
|
||||
await tool(
|
||||
name="sales_summary",
|
||||
body={"period": "مهر 1405", "pipeline_id": pipeline_id},
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await client.aclose()
|
||||
assert "mode: verified" in text
|
||||
assert "currency_verified: true" in text
|
||||
assert "panel_parity_verified: true" in text
|
||||
assert "Amount: IRR 80" in text
|
||||
assert "pipeline: فروش سازمانی" in text
|
||||
assert pipeline_id not in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raw_search_tells_the_model_not_to_count_the_page(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
):
|
||||
_enable(monkeypatch)
|
||||
monkeypatch.setattr("kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path))
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"Response": {
|
||||
"TotalCount": 651,
|
||||
"WonDealsCount": 651,
|
||||
"List": [{"Title": "Deal", "Status": "Won"}],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
executor, client = _client(handler)
|
||||
try:
|
||||
result = await executor.execute(
|
||||
connector_id="didar-a",
|
||||
function_name="search_deals",
|
||||
body={"Criteria": {}, "From": 0, "Limit": 100},
|
||||
react_state={"conv_id": "conv-semantic"},
|
||||
display_name="دیدار فروش",
|
||||
)
|
||||
finally:
|
||||
await client.aclose()
|
||||
assert "WonDealsCount: 651" in result["observation"]
|
||||
assert "only the current page" in result["observation"]
|
||||
@@ -0,0 +1,647 @@
|
||||
"""Memory lifecycle: queues, ReAct scratch, adapter cache, DuckDB caps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import gc
|
||||
import json
|
||||
import tracemalloc
|
||||
from types import SimpleNamespace
|
||||
|
||||
import psutil
|
||||
import pytest
|
||||
|
||||
from kamidso.engine.app.openapi.api_v1.react_runtime import (
|
||||
REACT_AGENT_MEMORY_CACHE,
|
||||
REACT_CONVERSATION_LOCKS,
|
||||
acquire_conversation_run,
|
||||
conversation_lock_refcount,
|
||||
end_react_turn,
|
||||
release_conversation_run,
|
||||
)
|
||||
from kamidso.engine.core.agent.core.memory.gpts.base import GptsMessage
|
||||
from kamidso.engine.core.agent.core.memory.gpts.gpts_memory import GptsMemory
|
||||
from kamidso.engine.core.datasource import duckdb_memory as duck_mem
|
||||
from kamidso.integrations.engine import events as adapter_events
|
||||
from kamidso.integrations.engine.engine import KamidsoEngine
|
||||
from kamidso.integrations.engine.events import (
|
||||
adapter_cache_size,
|
||||
adapter_run_is_active,
|
||||
begin_adapter_run,
|
||||
map_engine_event,
|
||||
release_adapter_run,
|
||||
sweep_adapter_cache,
|
||||
)
|
||||
|
||||
|
||||
def _message(
|
||||
conv_id: str,
|
||||
content: str,
|
||||
*,
|
||||
rounds: int = 0,
|
||||
sender: str = "ReAct",
|
||||
receiver: str = "Human",
|
||||
action_report: str | None = None,
|
||||
) -> GptsMessage:
|
||||
return GptsMessage(
|
||||
conv_id=conv_id,
|
||||
sender=sender,
|
||||
receiver=receiver,
|
||||
role="ai" if sender != "Human" else "human",
|
||||
content=content,
|
||||
rounds=rounds,
|
||||
app_code="react_agent",
|
||||
app_name="ReAct",
|
||||
action_report=action_report,
|
||||
)
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self) -> None:
|
||||
self.rows: list[GptsMessage] = []
|
||||
self.deleted: list[str] = []
|
||||
|
||||
def append(self, message: GptsMessage) -> None:
|
||||
self.rows.append(message)
|
||||
|
||||
def get_by_conv_id(self, conv_id: str) -> list[GptsMessage]:
|
||||
return [row for row in self.rows if row.conv_id == conv_id]
|
||||
|
||||
def get_by_agent(self, conv_id: str, agent: str) -> list[GptsMessage]:
|
||||
return [
|
||||
row
|
||||
for row in self.rows
|
||||
if row.conv_id == conv_id and (row.sender == agent or row.receiver == agent)
|
||||
]
|
||||
|
||||
def get_between_agents(self, conv_id: str, agent1: str, agent2: str, current_goal: str | None = None):
|
||||
del conv_id, agent1, agent2, current_goal
|
||||
return []
|
||||
|
||||
def get_last_message(self, conv_id: str) -> GptsMessage | None:
|
||||
rows = self.get_by_conv_id(conv_id)
|
||||
return rows[-1] if rows else None
|
||||
|
||||
def delete_by_conv_id(self, conv_id: str) -> None:
|
||||
self.deleted.append(conv_id)
|
||||
|
||||
|
||||
class _Plans:
|
||||
def __init__(self) -> None:
|
||||
self.removed: list[str] = []
|
||||
|
||||
def remove_by_conv_id(self, conv_id: str) -> None:
|
||||
self.removed.append(conv_id)
|
||||
|
||||
|
||||
class _LatestStore(_Store):
|
||||
"""Keeps one row so a repeated-load test does not retain every payload."""
|
||||
|
||||
def append(self, message: GptsMessage) -> None:
|
||||
self.rows = [message]
|
||||
|
||||
|
||||
def _memory(store: _Store | None = None) -> tuple[GptsMemory, _Store, _Plans]:
|
||||
store = store or _Store()
|
||||
plans = _Plans()
|
||||
return GptsMemory(plans_memory=plans, message_memory=store), store, plans
|
||||
|
||||
|
||||
def _reset_runtime() -> None:
|
||||
REACT_AGENT_MEMORY_CACHE.clear()
|
||||
REACT_CONVERSATION_LOCKS.clear()
|
||||
from kamidso.engine.app.openapi.api_v1 import react_runtime
|
||||
|
||||
react_runtime._LOCK_REFS.clear()
|
||||
|
||||
|
||||
def test_vis_off_without_reader_does_not_queue_history_snapshot():
|
||||
async def _run() -> None:
|
||||
memory, store, plans = _memory()
|
||||
conv = "conv-quiet"
|
||||
|
||||
async def _forbidden(*_args, **_kwargs):
|
||||
raise AssertionError("full history snapshot was built")
|
||||
|
||||
memory.simple_message = _forbidden # type: ignore[method-assign]
|
||||
memory.init(conv, enable_vis_message=False)
|
||||
payload = "x" * 50_000
|
||||
for index in range(6):
|
||||
await memory.append_message(
|
||||
conv,
|
||||
_message(
|
||||
conv,
|
||||
payload,
|
||||
rounds=index,
|
||||
action_report=json.dumps({"content": f"step-{index}-" + payload[:32]}),
|
||||
),
|
||||
)
|
||||
queue = memory.queue(conv)
|
||||
assert queue is not None and queue.empty()
|
||||
assert len(store.rows) == 6
|
||||
assert store.deleted == []
|
||||
assert plans.removed == []
|
||||
memory.dispose_runtime(conv)
|
||||
assert memory.queue(conv) is None
|
||||
assert store.deleted == []
|
||||
assert plans.removed == []
|
||||
assert len(store.rows) == 6
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_vis_off_reader_receives_only_the_latest_message():
|
||||
async def _run() -> None:
|
||||
memory, _store, _plans = _memory()
|
||||
conv = "conv-reader"
|
||||
memory.init(conv, enable_vis_message=False)
|
||||
memory.attach_queue_reader(conv)
|
||||
for index in range(4):
|
||||
await memory.append_message(
|
||||
conv,
|
||||
_message(
|
||||
conv,
|
||||
f"body-{index}",
|
||||
rounds=index,
|
||||
action_report=json.dumps({"content": f"view-{index}"}),
|
||||
),
|
||||
)
|
||||
queued = []
|
||||
queue = memory.queue(conv)
|
||||
assert queue is not None
|
||||
while not queue.empty():
|
||||
queued.append(queue.get_nowait())
|
||||
assert [item[0]["markdown"] for item in queued] == [f"view-{i}" for i in range(4)]
|
||||
assert all(len(item) == 1 for item in queued)
|
||||
memory.detach_queue_reader(conv)
|
||||
await memory.complete(conv)
|
||||
assert queue.get_nowait() == "[DONE]"
|
||||
memory.dispose_runtime(conv)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_vis_on_still_publishes_the_legacy_view():
|
||||
async def _run() -> None:
|
||||
memory, _store, _plans = _memory()
|
||||
conv = "conv-vis"
|
||||
|
||||
async def _link(_conv_id: str) -> str:
|
||||
return "VIS-SNAPSHOT"
|
||||
|
||||
memory.app_link_chat_message = _link # type: ignore[method-assign]
|
||||
memory.init(conv, enable_vis_message=True)
|
||||
await memory.append_message(conv, _message(conv, "hello", rounds=0))
|
||||
assert memory.queue(conv).get_nowait() == "VIS-SNAPSHOT"
|
||||
memory.clear(conv)
|
||||
assert memory._executor is not None
|
||||
memory.shutdown_executor()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_clear_does_not_delete_persisted_rows_or_owned_executor():
|
||||
async def _run() -> None:
|
||||
memory, store, plans = _memory()
|
||||
conv = "conv-clear"
|
||||
memory.init(conv, enable_vis_message=False)
|
||||
await memory.append_message(conv, _message(conv, "kept", rounds=0))
|
||||
executor = memory._executor
|
||||
memory.clear(conv)
|
||||
assert store.get_by_conv_id(conv)[0].content == "kept"
|
||||
assert store.deleted == []
|
||||
assert plans.removed == []
|
||||
assert memory._executor is executor
|
||||
memory.shutdown_executor()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_next_turn_reloads_history_after_dispose():
|
||||
async def _run() -> None:
|
||||
memory, store, plans = _memory()
|
||||
conv = "conv-reload"
|
||||
report = json.dumps({"content": "persisted-answer", "is_exe_success": True})
|
||||
memory.init(conv, enable_vis_message=False)
|
||||
await memory.append_message(
|
||||
conv,
|
||||
_message(conv, "what is revenue?", rounds=0, sender="Human", receiver="ReAct"),
|
||||
)
|
||||
await memory.append_message(
|
||||
conv,
|
||||
_message(conv, "persisted-answer", rounds=1, sender="ReAct", action_report=report),
|
||||
)
|
||||
memory.dispose_runtime(conv)
|
||||
assert memory._executor is None
|
||||
assert store.deleted == []
|
||||
assert plans.removed == []
|
||||
|
||||
reloaded, _store, plans2 = _memory(store)
|
||||
reloaded.init(conv, enable_vis_message=False)
|
||||
rows = await reloaded.get_messages(conv)
|
||||
assert [row.content for row in rows] == ["what is revenue?", "persisted-answer"]
|
||||
history = await reloaded.get_agent_history_memory(conv, "ReAct")
|
||||
assert history and history[0].content == "persisted-answer"
|
||||
assert plans2.removed == []
|
||||
reloaded.dispose_runtime(conv)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_metadb_rows_survive_dispose(tmp_path):
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from kamidso.engine.core.storage.metadata.db_manager import db
|
||||
from kamidso.engine.serve.agent.agents.db_gpts_memory import (
|
||||
MetaDbGptsMessageMemory,
|
||||
MetaDbGptsPlansMemory,
|
||||
)
|
||||
|
||||
db_file = tmp_path / "lifecycle.db"
|
||||
db.init_db(
|
||||
f"sqlite:///{db_file.as_posix()}",
|
||||
engine_args={"connect_args": {"check_same_thread": False}, "poolclass": NullPool},
|
||||
)
|
||||
db.create_all()
|
||||
|
||||
async def _run() -> None:
|
||||
conv = "conv-metadb-lifecycle"
|
||||
memory = GptsMemory(
|
||||
plans_memory=MetaDbGptsPlansMemory(),
|
||||
message_memory=MetaDbGptsMessageMemory(),
|
||||
)
|
||||
memory.init(conv, enable_vis_message=False)
|
||||
await memory.append_message(conv, _message(conv, "durable", rounds=1, sender="Human"))
|
||||
memory.dispose_runtime(conv)
|
||||
rows = MetaDbGptsMessageMemory().get_by_conv_id(conv)
|
||||
assert rows and rows[0].content == "durable"
|
||||
follow = GptsMemory(
|
||||
plans_memory=MetaDbGptsPlansMemory(),
|
||||
message_memory=MetaDbGptsMessageMemory(),
|
||||
)
|
||||
follow.init(conv, enable_vis_message=False)
|
||||
loaded = await follow.get_messages(conv)
|
||||
assert loaded[0].content == "durable"
|
||||
follow.dispose_runtime(conv)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_same_conversation_is_serialized_and_lock_survives_a_waiter():
|
||||
async def _run() -> None:
|
||||
_reset_runtime()
|
||||
conv = "conv-same"
|
||||
order: list[str] = []
|
||||
locks: list[asyncio.Lock] = []
|
||||
|
||||
class _Scratch:
|
||||
def __init__(self) -> None:
|
||||
self.disposed = False
|
||||
|
||||
def dispose_runtime(self, conv_id: str | None = None) -> None:
|
||||
del conv_id
|
||||
self.disposed = True
|
||||
|
||||
async def _turn(name: str, hold: float) -> None:
|
||||
lock = await acquire_conversation_run(conv)
|
||||
locks.append(lock)
|
||||
order.append(f"enter:{name}")
|
||||
try:
|
||||
if conv not in REACT_AGENT_MEMORY_CACHE:
|
||||
REACT_AGENT_MEMORY_CACHE[conv] = _Scratch()
|
||||
await asyncio.sleep(hold)
|
||||
order.append(f"exit:{name}")
|
||||
finally:
|
||||
await end_react_turn(conv, lock)
|
||||
|
||||
first = asyncio.create_task(_turn("a", 0.08))
|
||||
await asyncio.sleep(0.02)
|
||||
second = asyncio.create_task(_turn("b", 0.01))
|
||||
await asyncio.sleep(0.02)
|
||||
assert conversation_lock_refcount(conv) == 2
|
||||
assert REACT_CONVERSATION_LOCKS[conv].locked()
|
||||
await first
|
||||
await second
|
||||
assert order == ["enter:a", "exit:a", "enter:b", "exit:b"]
|
||||
assert locks[0] is locks[1]
|
||||
assert conv not in REACT_CONVERSATION_LOCKS
|
||||
assert conv not in REACT_AGENT_MEMORY_CACHE
|
||||
assert conversation_lock_refcount(conv) == 0
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_cancelled_waiter_does_not_pin_the_conversation_lock():
|
||||
async def _run() -> None:
|
||||
_reset_runtime()
|
||||
conv = "conv-cancel-wait"
|
||||
holder = await acquire_conversation_run(conv)
|
||||
|
||||
async def _wait() -> None:
|
||||
await acquire_conversation_run(conv)
|
||||
|
||||
waiter = asyncio.create_task(_wait())
|
||||
await asyncio.sleep(0.05)
|
||||
assert conversation_lock_refcount(conv) == 2
|
||||
waiter.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await waiter
|
||||
assert conversation_lock_refcount(conv) == 1
|
||||
assert REACT_CONVERSATION_LOCKS[conv] is holder
|
||||
await release_conversation_run(conv, holder)
|
||||
assert conv not in REACT_CONVERSATION_LOCKS
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_twenty_different_conversations_run_together():
|
||||
async def _run() -> None:
|
||||
_reset_runtime()
|
||||
active = 0
|
||||
peak = 0
|
||||
|
||||
async def _one(index: int) -> None:
|
||||
nonlocal active, peak
|
||||
conv = f"user-{index}"
|
||||
lock = await acquire_conversation_run(conv)
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
await asyncio.sleep(0.05)
|
||||
active -= 1
|
||||
await release_conversation_run(conv, lock)
|
||||
|
||||
await asyncio.gather(*(_one(index) for index in range(20)))
|
||||
assert peak == 20
|
||||
assert REACT_CONVERSATION_LOCKS == {}
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_repeated_turns_release_scratch_and_allocated_memory_stabilizes():
|
||||
async def _cycle(store: _LatestStore, index: int) -> None:
|
||||
memory, _store, _plans = _memory(store)
|
||||
conv = "conv-repeat"
|
||||
memory.init(conv, enable_vis_message=False)
|
||||
payload = ("row-" + str(index) + "-") * 4_000
|
||||
for step in range(6):
|
||||
await memory.append_message(
|
||||
conv,
|
||||
_message(
|
||||
conv,
|
||||
payload,
|
||||
rounds=step,
|
||||
action_report=json.dumps({"content": payload[:64]}),
|
||||
),
|
||||
)
|
||||
assert memory.queue(conv).empty()
|
||||
executor = memory._executor
|
||||
memory.dispose_runtime(conv)
|
||||
assert executor is not None and getattr(executor, "_shutdown", False)
|
||||
assert memory._executor is None
|
||||
assert memory.queue(conv) is None
|
||||
assert conv not in memory.messages_cache
|
||||
|
||||
async def _run() -> None:
|
||||
store = _LatestStore()
|
||||
tracemalloc.start()
|
||||
alloc: list[int] = []
|
||||
rss: list[int] = []
|
||||
process = psutil.Process()
|
||||
try:
|
||||
for index in range(100):
|
||||
await _cycle(store, index)
|
||||
if index % 10 == 9:
|
||||
gc.collect()
|
||||
current, _peak = tracemalloc.get_traced_memory()
|
||||
alloc.append(current)
|
||||
rss.append(process.memory_info().rss)
|
||||
finally:
|
||||
tracemalloc.stop()
|
||||
growth = alloc[-1] - alloc[2]
|
||||
print(
|
||||
"LIFECYCLE_MEASURE",
|
||||
f"alloc_bytes={alloc}",
|
||||
f"rss_bytes={rss}",
|
||||
f"alloc_growth={growth}",
|
||||
f"rss_growth={rss[-1] - rss[2]}",
|
||||
)
|
||||
assert store.deleted == []
|
||||
assert len(store.rows) == 1
|
||||
# Warmup is the first samples. Later allocated memory must not climb
|
||||
# with the number of turns. RSS may stay above the process baseline.
|
||||
assert growth < 8 * 1024 * 1024
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_adapter_release_covers_done_error_and_abandoned_runs():
|
||||
done_id = "run-done"
|
||||
error_id = "run-error"
|
||||
quiet_id = "run-quiet"
|
||||
active_id = "run-active"
|
||||
try:
|
||||
begin_adapter_run(done_id)
|
||||
mapped = map_engine_event(done_id, {"type": "step.start", "id": "s", "title": "sql"})
|
||||
assert mapped and mapped[0]["type"] == "run.started"
|
||||
assert adapter_run_is_active(done_id)
|
||||
release_adapter_run(done_id)
|
||||
assert not adapter_run_is_active(done_id)
|
||||
|
||||
begin_adapter_run(error_id)
|
||||
try:
|
||||
map_engine_event(error_id, {"type": "step.start", "id": "s", "title": "code"})
|
||||
raise RuntimeError("stream failed")
|
||||
except RuntimeError:
|
||||
release_adapter_run(error_id)
|
||||
assert not adapter_run_is_active(error_id)
|
||||
|
||||
map_engine_event(quiet_id, {"type": "step.start", "id": "s", "title": "sql"})
|
||||
begin_adapter_run(active_id)
|
||||
map_engine_event(active_id, {"type": "step.start", "id": "s", "title": "sql"})
|
||||
with adapter_events._LOCK:
|
||||
adapter_events._TOUCHED[quiet_id] = 0.0
|
||||
adapter_events._TOUCHED[active_id] = 0.0
|
||||
removed = sweep_adapter_cache(now=10_000.0)
|
||||
assert quiet_id in removed
|
||||
assert active_id not in removed
|
||||
assert adapter_run_is_active(active_id)
|
||||
finally:
|
||||
release_adapter_run(done_id)
|
||||
release_adapter_run(error_id)
|
||||
release_adapter_run(quiet_id)
|
||||
release_adapter_run(active_id)
|
||||
|
||||
|
||||
def test_release_does_not_drop_another_active_run():
|
||||
first = "run-keep"
|
||||
second = "run-drop"
|
||||
try:
|
||||
begin_adapter_run(first)
|
||||
begin_adapter_run(second)
|
||||
map_engine_event(first, {"type": "step.start", "id": "a", "title": "sql"})
|
||||
map_engine_event(second, {"type": "step.start", "id": "b", "title": "sql"})
|
||||
release_adapter_run(second)
|
||||
assert adapter_run_is_active(first)
|
||||
assert not adapter_run_is_active(second)
|
||||
assert adapter_cache_size() >= 1
|
||||
finally:
|
||||
release_adapter_run(first)
|
||||
release_adapter_run(second)
|
||||
|
||||
|
||||
def test_engine_releases_adapter_on_success_error_and_disconnect():
|
||||
async def _collect(engine: KamidsoEngine, run_id: str):
|
||||
run = SimpleNamespace(id=run_id)
|
||||
events = []
|
||||
async for event in engine.execute(
|
||||
user=None, # type: ignore[arg-type]
|
||||
run=run, # type: ignore[arg-type]
|
||||
request=None, # type: ignore[arg-type]
|
||||
materialized=[],
|
||||
):
|
||||
events.append(event)
|
||||
return events
|
||||
|
||||
async def _run() -> None:
|
||||
engine = KamidsoEngine.__new__(KamidsoEngine)
|
||||
engine._runtime = SimpleNamespace(ready=False)
|
||||
failed = await _collect(engine, "run-unavailable")
|
||||
assert failed[0]["error"]["code"] == "ENGINE_UNAVAILABLE"
|
||||
assert not adapter_run_is_active("run-unavailable")
|
||||
|
||||
engine._runtime = SimpleNamespace(ready=True)
|
||||
|
||||
async def _ok(**_kwargs):
|
||||
yield {"type": "run.delta"}
|
||||
|
||||
engine._execute_ready = _ok # type: ignore[method-assign]
|
||||
events = await _collect(engine, "run-ok")
|
||||
assert events == [{"type": "run.delta"}]
|
||||
assert not adapter_run_is_active("run-ok")
|
||||
|
||||
async def _boom(**_kwargs):
|
||||
yield {"type": "partial"}
|
||||
raise RuntimeError("execution failed")
|
||||
|
||||
engine._execute_ready = _boom # type: ignore[method-assign]
|
||||
with pytest.raises(RuntimeError):
|
||||
await _collect(engine, "run-boom")
|
||||
assert not adapter_run_is_active("run-boom")
|
||||
|
||||
async def _slow(**_kwargs):
|
||||
yield {"type": "partial"}
|
||||
await asyncio.sleep(30)
|
||||
|
||||
engine._execute_ready = _slow # type: ignore[method-assign]
|
||||
generator = engine.execute(
|
||||
user=None, # type: ignore[arg-type]
|
||||
run=SimpleNamespace(id="run-disconnect"), # type: ignore[arg-type]
|
||||
request=None, # type: ignore[arg-type]
|
||||
materialized=[],
|
||||
)
|
||||
first = await generator.__anext__()
|
||||
assert first["type"] == "partial"
|
||||
await generator.aclose()
|
||||
assert not adapter_run_is_active("run-disconnect")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def _limit_bytes(text: str) -> float:
|
||||
number, unit = str(text).split()
|
||||
scales = {
|
||||
"b": 1,
|
||||
"byte": 1,
|
||||
"bytes": 1,
|
||||
"kib": 1024,
|
||||
"mib": 1024**2,
|
||||
"gib": 1024**3,
|
||||
"tib": 1024**4,
|
||||
"kb": 1000,
|
||||
"mb": 1000**2,
|
||||
"gb": 1000**3,
|
||||
"tb": 1000**4,
|
||||
}
|
||||
return float(number) * scales[unit.lower()]
|
||||
|
||||
|
||||
def test_duckdb_invalid_limit_falls_back_to_two_gigabytes(monkeypatch):
|
||||
monkeypatch.setenv("KAMIDSO_DUCKDB_MEMORY_LIMIT", "not-a-size")
|
||||
monkeypatch.delenv("KAMIDSO_DUCKDB_AGGREGATE_MEMORY_LIMIT", raising=False)
|
||||
assert duck_mem.configured_memory_limit() == "2GB"
|
||||
assert duck_mem.configured_aggregate_limit() == "2GB"
|
||||
|
||||
|
||||
def test_duckdb_tool_connections_share_one_aggregate_budget(monkeypatch):
|
||||
monkeypatch.setenv("KAMIDSO_DUCKDB_MEMORY_LIMIT", "2GB")
|
||||
monkeypatch.delenv("KAMIDSO_DUCKDB_AGGREGATE_MEMORY_LIMIT", raising=False)
|
||||
duck_mem.reset_duckdb_memory_state_for_tests()
|
||||
duck_mem.cap_duckdb_default_connection()
|
||||
first = duck_mem.open_tool_duckdb(":memory:")
|
||||
second = None
|
||||
try:
|
||||
alone = first.execute("SELECT current_setting('memory_limit')").fetchone()[0]
|
||||
assert _limit_bytes(alone) == pytest.approx(2_000_000_000, rel=0.15)
|
||||
assert len(duck_mem._LIVE) == 1
|
||||
second = duck_mem.open_tool_duckdb(":memory:")
|
||||
limits = [
|
||||
_limit_bytes(conn.execute("SELECT current_setting('memory_limit')").fetchone()[0])
|
||||
for conn in (first, second)
|
||||
]
|
||||
assert all(700 * 1024**2 < limit < 1.2 * 1024**3 for limit in limits)
|
||||
duck_mem.release_tool_duckdb(second)
|
||||
second.close()
|
||||
second = None
|
||||
restored = first.execute("SELECT current_setting('memory_limit')").fetchone()[0]
|
||||
assert _limit_bytes(restored) == pytest.approx(2_000_000_000, rel=0.15)
|
||||
finally:
|
||||
duck_mem.release_tool_duckdb(first)
|
||||
first.close()
|
||||
if second is not None:
|
||||
duck_mem.release_tool_duckdb(second)
|
||||
second.close()
|
||||
duck_mem.reset_duckdb_memory_state_for_tests()
|
||||
|
||||
|
||||
def test_excel_reader_registers_and_releases_duckdb(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("KAMIDSO_DUCKDB_MEMORY_LIMIT", "2GB")
|
||||
monkeypatch.delenv("KAMIDSO_DUCKDB_AGGREGATE_MEMORY_LIMIT", raising=False)
|
||||
duck_mem.reset_duckdb_memory_state_for_tests()
|
||||
from kamidso.engine.app.scene.chat_data.chat_excel.excel_reader import ExcelReader
|
||||
|
||||
csv_path = tmp_path / "tiny.csv"
|
||||
csv_path.write_text("city,amount\nShiraz,10\nTehran,20\n", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
"kamidso.engine.core.util.file_client.knowledge_upload_root",
|
||||
lambda: str(tmp_path),
|
||||
)
|
||||
reader = ExcelReader("conv-excel", str(csv_path), read_type="df")
|
||||
live_after_close = None
|
||||
try:
|
||||
limit = reader.db.execute("SELECT current_setting('memory_limit')").fetchone()[0]
|
||||
assert _limit_bytes(limit) == pytest.approx(2_000_000_000, rel=0.15)
|
||||
assert len(duck_mem._LIVE) == 1
|
||||
rows = reader.db.execute("SELECT COUNT(*) FROM temp_table").fetchone()[0]
|
||||
assert rows == 2
|
||||
finally:
|
||||
reader.close()
|
||||
live_after_close = dict(duck_mem._LIVE)
|
||||
duck_mem.reset_duckdb_memory_state_for_tests()
|
||||
assert reader.db is None
|
||||
assert live_after_close == {}
|
||||
|
||||
|
||||
def test_duckdb_connector_binds_sqlalchemy_connections(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("KAMIDSO_DUCKDB_MEMORY_LIMIT", "2GB")
|
||||
monkeypatch.delenv("KAMIDSO_DUCKDB_AGGREGATE_MEMORY_LIMIT", raising=False)
|
||||
duck_mem.reset_duckdb_memory_state_for_tests()
|
||||
from kamidso.engine.ext.datasource.rdbms.conn_duckdb import DuckDbConnector
|
||||
|
||||
connector = DuckDbConnector.from_file_path(str(tmp_path / "analytics.duckdb"))
|
||||
try:
|
||||
with connector._engine.connect() as connection:
|
||||
limit = connection.exec_driver_sql("SELECT current_setting('memory_limit')").scalar()
|
||||
assert _limit_bytes(str(limit)) == pytest.approx(2_000_000_000, rel=0.15)
|
||||
finally:
|
||||
connector._engine.dispose()
|
||||
duck_mem.reset_duckdb_memory_state_for_tests()
|
||||
@@ -0,0 +1,482 @@
|
||||
"""Native Didar connector: catalog, validation, HTTP boundary, and handoff."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from kamidso.contracts.chat import KamidsoChatRequest
|
||||
from kamidso.engine.app.openapi.api_v1.tools.code_interpreter import make_code_interpreter
|
||||
from kamidso.engine.app.openapi.api_v1.tools.connector_call import make_connector_call
|
||||
from kamidso.engine.core.agent.resource.connector.credential import CredentialStore
|
||||
from kamidso.integrations.engine.engine import connector_selection
|
||||
from kamidso.native_connectors.errors import CatalogError
|
||||
from kamidso.native_connectors.executor import NativeConnectorExecutor
|
||||
from kamidso.native_connectors.prompt import render_native_prompt
|
||||
from kamidso.native_connectors.registry import (
|
||||
NativeConnectorRegistry,
|
||||
get_registry,
|
||||
reset_registry,
|
||||
)
|
||||
from kamidso.native_connectors.resolve import partition_connector_ids
|
||||
from kamidso.native_connectors.result_store import materialize_connector_result
|
||||
|
||||
|
||||
def _enable(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("KAMIDSO_NATIVE_CONNECTORS_V1", "1")
|
||||
monkeypatch.setenv("KAMIDSO_DIDAR_CONNECTOR", "1")
|
||||
monkeypatch.delenv("KAMIDSO_ENV", raising=False)
|
||||
reset_registry()
|
||||
|
||||
|
||||
def _provider():
|
||||
return get_registry().get("didar")
|
||||
|
||||
|
||||
def test_didar_catalog_is_fixed_and_read_only(monkeypatch: pytest.MonkeyPatch):
|
||||
_enable(monkeypatch)
|
||||
provider = _provider()
|
||||
names = [item.name for item in provider.functions]
|
||||
assert len(names) == 49
|
||||
assert len(set(names)) == 49
|
||||
assert provider.base_url == "https://app.didar.me"
|
||||
for function in provider.functions:
|
||||
assert function.path.startswith("/")
|
||||
assert "://" not in function.path
|
||||
assert function.method in {"GET", "POST", "PUT", "PATCH", "DELETE"}
|
||||
if function.enabled:
|
||||
assert function.effect == "read"
|
||||
deals = provider.function("search_deals")
|
||||
assert deals is not None
|
||||
assert deals.input_schema["required"] == ["Criteria", "From", "Limit"]
|
||||
assert deals.records_path == "Response.List"
|
||||
|
||||
|
||||
def test_invalid_catalog_fails_fast_outside_production(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("KAMIDSO_NATIVE_CONNECTORS_V1", "1")
|
||||
monkeypatch.delenv("KAMIDSO_ENV", raising=False)
|
||||
_write_bad_provider(tmp_path)
|
||||
with pytest.raises(CatalogError):
|
||||
NativeConnectorRegistry.load(tmp_path)
|
||||
|
||||
|
||||
def test_invalid_catalog_is_disabled_in_production(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("KAMIDSO_NATIVE_CONNECTORS_V1", "1")
|
||||
monkeypatch.setenv("KAMIDSO_ENV", "production")
|
||||
_write_bad_provider(tmp_path)
|
||||
registry = NativeConnectorRegistry.load(tmp_path)
|
||||
assert registry.provider_ids() == []
|
||||
|
||||
|
||||
def _write_bad_provider(root):
|
||||
directory = root / "bad"
|
||||
directory.mkdir()
|
||||
(directory / "manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"id": "bad",
|
||||
"version": "0",
|
||||
"display_name": "Bad",
|
||||
"base_url": "https://example.com",
|
||||
"authentication": {
|
||||
"type": "api_key_query",
|
||||
"parameter_name": "apikey",
|
||||
"credential_field": "api_key",
|
||||
},
|
||||
"mode": "read_only",
|
||||
"health_check": {"function": "ping", "body": {}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(directory / "functions.json").write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"name": "ping",
|
||||
"description": "ping",
|
||||
"entity": "ping",
|
||||
"effect": "read",
|
||||
"enabled": True,
|
||||
"method": "GET",
|
||||
"path": "/ping",
|
||||
"input_schema": {"type": "object"},
|
||||
},
|
||||
{
|
||||
"name": "ping",
|
||||
"description": "duplicate",
|
||||
"entity": "ping",
|
||||
"effect": "read",
|
||||
"enabled": True,
|
||||
"method": "GET",
|
||||
"path": "/ping",
|
||||
"input_schema": {"type": "object"},
|
||||
},
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _row(store: CredentialStore, secret: str = "super-secret-key") -> dict:
|
||||
salt = store.generate_salt()
|
||||
return {
|
||||
"connector_id": "didar-a",
|
||||
"connector_type": "didar",
|
||||
"display_name": "دیدار فروش",
|
||||
"status": "active",
|
||||
"encrypted_credentials": store.encrypt({"api_key": secret}, salt),
|
||||
"encryption_salt": salt,
|
||||
"user_name": "usr_test",
|
||||
}
|
||||
|
||||
|
||||
def _executor(handler, row, store: CredentialStore):
|
||||
transport = httpx.MockTransport(handler)
|
||||
client = httpx.AsyncClient(transport=transport)
|
||||
executor = NativeConnectorExecutor(
|
||||
client=client,
|
||||
credential_store=store,
|
||||
row_loader=lambda connector_id: row if connector_id == row["connector_id"] else None,
|
||||
)
|
||||
return executor, client
|
||||
|
||||
|
||||
def _state():
|
||||
return {
|
||||
"conv_id": "conv-native",
|
||||
"user_name": "usr_test",
|
||||
"native_connectors": {
|
||||
"didar-a": {
|
||||
"connector_id": "didar-a",
|
||||
"connector_type": "didar",
|
||||
"display_name": "دیدار فروش",
|
||||
},
|
||||
"didar-b": {
|
||||
"connector_id": "didar-b",
|
||||
"connector_type": "didar",
|
||||
"display_name": "دیدار شیراز",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_deals_validation_and_one_http_call(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
):
|
||||
_enable(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path)
|
||||
)
|
||||
calls = {"n": 0}
|
||||
secret = "super-secret-key"
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls["n"] += 1
|
||||
assert request.url.host == "app.didar.me"
|
||||
assert request.url.path == "/api/deal/search_v2"
|
||||
assert request.url.params["apikey"] == secret
|
||||
assert secret not in (request.content.decode() or "")
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"Response": {
|
||||
"TotalCount": 651,
|
||||
"TotalPrice": 10,
|
||||
"WonDealsCount": 651,
|
||||
"LostDealsCount": 0,
|
||||
"PendingDealsCount": 0,
|
||||
"List": [{"Title": "Deal"}],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
store = CredentialStore()
|
||||
row = _row(store, secret)
|
||||
executor, client = _executor(handler, row, store)
|
||||
try:
|
||||
missing = await executor.execute(
|
||||
connector_id="didar-a",
|
||||
function_name="search_deals",
|
||||
body={"From": 0, "Limit": 100},
|
||||
react_state={"conv_id": "conv-native"},
|
||||
display_name="دیدار فروش",
|
||||
)
|
||||
assert missing["error_type"] == "validation_error"
|
||||
assert missing["http_sent"] is False
|
||||
assert calls["n"] == 0
|
||||
nested = await executor.execute(
|
||||
connector_id="didar-a",
|
||||
function_name="search_deals",
|
||||
body={"Criteria": {"From": 0, "Limit": 50}},
|
||||
react_state={"conv_id": "conv-native"},
|
||||
display_name="دیدار فروش",
|
||||
)
|
||||
assert nested["http_sent"] is False
|
||||
assert any(issue["message"].startswith("Unexpected fields") for issue in nested["issues"])
|
||||
assert calls["n"] == 0
|
||||
unknown = None
|
||||
try:
|
||||
await executor.execute(
|
||||
connector_id="didar-a",
|
||||
function_name="get_leads",
|
||||
body={},
|
||||
react_state={"conv_id": "conv-native"},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
unknown = exc
|
||||
assert unknown is not None
|
||||
payload = unknown.to_payload()
|
||||
assert payload["error_type"] == "unknown_function"
|
||||
assert payload["http_sent"] is False
|
||||
assert "search_persons" in payload["suggestions"]
|
||||
assert "search_deals" in payload["suggestions"]
|
||||
assert calls["n"] == 0
|
||||
try:
|
||||
await executor.execute(
|
||||
connector_id="didar-a",
|
||||
function_name="create_deal",
|
||||
body={"Deal": {}},
|
||||
react_state={"conv_id": "conv-native"},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
blocked = exc
|
||||
else:
|
||||
blocked = None
|
||||
assert blocked is not None
|
||||
blocked_payload = blocked.to_payload()
|
||||
assert blocked_payload["error_type"] == "operation_not_allowed"
|
||||
assert blocked_payload["http_sent"] is False
|
||||
assert calls["n"] == 0
|
||||
ok = await executor.execute(
|
||||
connector_id="didar-a",
|
||||
function_name="search_deals",
|
||||
body={"Criteria": {}, "From": 0, "Limit": 100},
|
||||
react_state={"conv_id": "conv-native"},
|
||||
display_name="دیدار فروش",
|
||||
)
|
||||
assert ok["ok"] is True
|
||||
assert calls["n"] == 1
|
||||
saved = tmp_path / "tmp" / "conv-native" / ok["relative_path"].replace("/", os.sep)
|
||||
raw = json.loads(saved.read_text(encoding="utf-8"))
|
||||
assert raw["Response"]["TotalCount"] == 651
|
||||
assert secret not in saved.read_text(encoding="utf-8")
|
||||
assert secret not in ok["observation"]
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_connector_call_does_not_repeat_http(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
):
|
||||
_enable(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path)
|
||||
)
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls["n"] += 1
|
||||
return httpx.Response(200, json={"Response": {"List": [], "TotalCount": 0}})
|
||||
|
||||
store = CredentialStore()
|
||||
row = _row(store)
|
||||
executor, client = _executor(handler, row, store)
|
||||
state = {
|
||||
"conv_id": "conv-native",
|
||||
"native_connectors": {
|
||||
"didar-a": {
|
||||
"connector_id": "didar-a",
|
||||
"connector_type": "didar",
|
||||
"display_name": "دیدار فروش",
|
||||
}
|
||||
},
|
||||
}
|
||||
tool = make_connector_call(state, executor=executor)
|
||||
body = {"Criteria": {}, "From": 0, "Limit": 1}
|
||||
try:
|
||||
first = await tool(name="get_users", body={})
|
||||
second = await tool(name="get_users", body={})
|
||||
third = await tool(name="get_users", body={})
|
||||
changed = await tool(name="search_deals", body=body)
|
||||
finally:
|
||||
await client.aclose()
|
||||
assert calls["n"] == 2
|
||||
assert "Reuse previous result" in first or "Reuse previous result" in second
|
||||
assert "already executed" in third
|
||||
assert "super-secret-key" not in first + second + third + changed
|
||||
assert "api_key" not in json.dumps(state["native_connectors"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validation_failure_loop_stops_repeating_the_schema(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
_enable(monkeypatch)
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls["n"] += 1
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
store = CredentialStore()
|
||||
executor, client = _executor(handler, _row(store), store)
|
||||
state = {
|
||||
"conv_id": "conv-native",
|
||||
"native_connectors": {
|
||||
"didar-a": {
|
||||
"connector_id": "didar-a",
|
||||
"connector_type": "didar",
|
||||
"display_name": "دیدار فروش",
|
||||
}
|
||||
},
|
||||
}
|
||||
tool = make_connector_call(state, executor=executor)
|
||||
try:
|
||||
first = await tool(name="search_deals", body={"From": 0})
|
||||
second = await tool(name="search_deals", body={"From": 0})
|
||||
third = await tool(name="search_deals", body={"From": 0})
|
||||
finally:
|
||||
await client.aclose()
|
||||
assert calls["n"] == 0
|
||||
assert "expected_schema" in first
|
||||
assert "expected_schema" in second
|
||||
assert "failed repeatedly" in third
|
||||
assert "expected_schema" not in third
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unattached_connector_is_rejected(monkeypatch: pytest.MonkeyPatch):
|
||||
_enable(monkeypatch)
|
||||
state = _state()
|
||||
del state["native_connectors"]["didar-b"]
|
||||
tool = make_connector_call(state, executor=NativeConnectorExecutor())
|
||||
text = await tool(name="get_users", connection="دیدار شیراز")
|
||||
assert "connector_not_attached" in text
|
||||
assert "super-secret" not in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_several_connectors_require_a_connection(monkeypatch: pytest.MonkeyPatch):
|
||||
_enable(monkeypatch)
|
||||
tool = make_connector_call(_state(), executor=NativeConnectorExecutor())
|
||||
text = await tool(name="get_users")
|
||||
assert "connector_ambiguous" in text
|
||||
assert "دیدار فروش" in text
|
||||
assert "دیدار شیراز" in text
|
||||
|
||||
|
||||
def test_prompt_lists_names_without_schema_or_secrets(monkeypatch: pytest.MonkeyPatch):
|
||||
_enable(monkeypatch)
|
||||
text = render_native_prompt(
|
||||
[
|
||||
{
|
||||
"connector_id": "didar-a",
|
||||
"connector_type": "didar",
|
||||
"display_name": "دیدار فروش",
|
||||
}
|
||||
]
|
||||
)
|
||||
assert "search_deals" in text
|
||||
assert "get_leads" in text
|
||||
assert "additionalProperties" not in text
|
||||
assert "/api/" not in text
|
||||
assert "api_key" not in text
|
||||
assert "apikey" not in text
|
||||
|
||||
|
||||
def test_partition_keeps_native_ids_away_from_mcp(monkeypatch: pytest.MonkeyPatch):
|
||||
_enable(monkeypatch)
|
||||
rows = {
|
||||
"didar-a": {
|
||||
"connector_id": "didar-a",
|
||||
"connector_type": "didar",
|
||||
"display_name": "دیدار فروش",
|
||||
"status": "active",
|
||||
"user_name": "usr_test",
|
||||
"api_key": "must-not-leak",
|
||||
},
|
||||
"mcp-1": {
|
||||
"connector_id": "mcp-1",
|
||||
"connector_type": "github",
|
||||
"display_name": "GitHub",
|
||||
"status": "active",
|
||||
"user_name": "usr_test",
|
||||
},
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"kamidso.native_connectors.resolve.default_row_loader",
|
||||
lambda connector_id: rows.get(connector_id),
|
||||
)
|
||||
mcp_ids, native = partition_connector_ids(["didar-a", "mcp-1", "missing"])
|
||||
assert mcp_ids == ["mcp-1", "missing"]
|
||||
assert native == [
|
||||
{
|
||||
"connector_id": "didar-a",
|
||||
"connector_type": "didar",
|
||||
"display_name": "دیدار فروش",
|
||||
}
|
||||
]
|
||||
assert "api_key" not in json.dumps(native)
|
||||
|
||||
|
||||
def test_result_file_survives_a_fresh_python_process(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
"kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path)
|
||||
)
|
||||
meta = materialize_connector_result(
|
||||
{"conv_id": "conv-native"},
|
||||
{"Response": {"TotalCount": 651, "List": [{"Title": "Deal"}]}},
|
||||
)
|
||||
work = tmp_path / "tmp" / "conv-native"
|
||||
env = os.environ.copy()
|
||||
env["PLOT_DIR"] = str(work)
|
||||
script = (
|
||||
"import json, os\n"
|
||||
"path = os.path.join(os.environ['PLOT_DIR'], 'connector_results', 'c_001.json')\n"
|
||||
"print(json.load(open(path, encoding='utf-8'))['Response']['TotalCount'])\n"
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert completed.stdout.strip() == "651"
|
||||
assert meta["relative_path"] == "connector_results/c_001.json"
|
||||
|
||||
|
||||
def test_code_interpreter_mentions_connector_results_when_attached():
|
||||
attached = make_code_interpreter({}, sql_enabled=False, native_connector=True)
|
||||
plain = make_code_interpreter({}, sql_enabled=False, native_connector=False)
|
||||
assert "connector_results" in attached._tool.description
|
||||
assert "requests/httpx" in attached._tool.description
|
||||
assert "connector_results" not in plain._tool.description
|
||||
|
||||
|
||||
def test_chat_resources_map_onto_connector_ids():
|
||||
request = KamidsoChatRequest.from_body(
|
||||
{
|
||||
"message": "چند معامله داریم؟",
|
||||
"resources": [
|
||||
{"id": "didar-a", "type": "connector", "name": "دیدار فروش"},
|
||||
{"id": "file-1", "type": "file", "name": "budget.xlsx"},
|
||||
],
|
||||
}
|
||||
)
|
||||
selected = connector_selection(request.resources)
|
||||
assert selected == {
|
||||
"connector_ids": ["didar-a"],
|
||||
"connector_labels": ["دیدار فروش"],
|
||||
}
|
||||
@@ -5,7 +5,12 @@ from collections.abc import AsyncIterator
|
||||
|
||||
import pytest
|
||||
|
||||
from kamidso.api.sse import SSE_PING, with_sse_keepalive
|
||||
from kamidso.api.sse import SSE_HEADERS, SSE_PING, with_sse_keepalive
|
||||
|
||||
|
||||
def test_sse_headers_do_not_ask_proxies_to_reuse_the_socket():
|
||||
assert "Connection" not in SSE_HEADERS
|
||||
assert SSE_HEADERS["X-Accel-Buffering"] == "no"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in new issue
Block a user