From cdf0c360bf65c0f4434859cf6f4a200a134e381b Mon Sep 17 00:00:00 2001 From: Alexander Ivanov Date: Wed, 24 Jun 2026 16:21:30 +0300 Subject: [PATCH 1/2] add enterprise pipeline with example --- artifacts/enterprise_review.docx | Bin 0 -> 37917 bytes artifacts/enterprise_review.xlsx | Bin 0 -> 5922 bytes examples/enterprise_vllm_two_questions.py | 371 +++++++ pyproject.toml | 3 +- requirements.txt | 1 + src/ragflow_orchestrator/__init__.py | 20 + .../enterprise/__init__.py | 44 + .../enterprise/adapters.py | 61 ++ src/ragflow_orchestrator/enterprise/config.py | 65 ++ src/ragflow_orchestrator/enterprise/models.py | 70 ++ .../enterprise/pipeline.py | 923 ++++++++++++++++++ src/ragflow_orchestrator/enterprise/tools.py | 68 ++ tests/test_enterprise_pipeline.py | 212 ++++ 13 files changed, 1837 insertions(+), 1 deletion(-) create mode 100644 artifacts/enterprise_review.docx create mode 100644 artifacts/enterprise_review.xlsx create mode 100644 examples/enterprise_vllm_two_questions.py create mode 100644 src/ragflow_orchestrator/enterprise/__init__.py create mode 100644 src/ragflow_orchestrator/enterprise/adapters.py create mode 100644 src/ragflow_orchestrator/enterprise/config.py create mode 100644 src/ragflow_orchestrator/enterprise/models.py create mode 100644 src/ragflow_orchestrator/enterprise/pipeline.py create mode 100644 src/ragflow_orchestrator/enterprise/tools.py create mode 100644 tests/test_enterprise_pipeline.py diff --git a/artifacts/enterprise_review.docx b/artifacts/enterprise_review.docx new file mode 100644 index 0000000000000000000000000000000000000000..8e07755223445760d985ed178e37f6c31063e0a4 GIT binary patch literal 37917 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 A-v9sr literal 0 HcmV?d00001 diff --git a/examples/enterprise_vllm_two_questions.py b/examples/enterprise_vllm_two_questions.py new file mode 100644 index 0000000..8f14b5b --- /dev/null +++ b/examples/enterprise_vllm_two_questions.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +import time +from pathlib import Path + +from ragflow_orchestrator.enterprise import ( + EnterpriseAnswerLLMConfig, + EnterpriseIntentLLMConfig, + EnterprisePipeline, + EnterprisePipelineConfig, + ReviewBundleRequest, +) +from ragflow_orchestrator.enterprise.tools import ToolContext +from ragflow_orchestrator.models import BaseChunk, RetrievalResult + + +class TracingEnterprisePipeline(EnterprisePipeline): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.last_prompt_source: str = "unknown" + self._reset_timing() + + def _reset_timing(self) -> None: + self.timing: dict[str, object] = { + "llm_calls_total": 0, + "llm_time_total_s": 0.0, + "prompt_tokens_total": 0, + "completion_tokens_total": 0, + "tokens_total": 0, + "intent_calls": 0, + "intent_time_s": 0.0, + "answer_calls": 0, + "answer_time_s": 0.0, + "planner_calls": 0, + "planner_time_s": 0.0, + "llm_calls": [], + "review_bundle_time_s": 0.0, + } + + def review_bundle(self, request_payload: ReviewBundleRequest): # type: ignore[override] + self._reset_timing() + started = time.perf_counter() + result = super().review_bundle(request_payload) + self.timing["review_bundle_time_s"] = time.perf_counter() - started + return result + + def _build_prompt(self, req, tasks, grouped, task_outputs, risk_result) -> str: # type: ignore[no-untyped-def] + if self.config.bundle.use_prompt_orchestrator and self._check_prompt_orchestrator_available(): + built = self._build_prompt_via_prompt_orchestrator(req=req, grouped=grouped) + if built: + self.last_prompt_source = "prompt_orchestrator" + return built + + self.last_prompt_source = "fallback" + was_enabled = self.config.bundle.use_prompt_orchestrator + self.config.bundle.use_prompt_orchestrator = False + try: + return super()._build_prompt(req, tasks, grouped, task_outputs, risk_result) + finally: + self.config.bundle.use_prompt_orchestrator = was_enabled + + def _call_llm_text(self, *, prompt: str, cfg, custom_client): # type: ignore[override,no-untyped-def] + started = time.perf_counter() + result = super()._call_llm_text(prompt=prompt, cfg=cfg, custom_client=custom_client) + duration = time.perf_counter() - started + + provider = str(getattr(cfg, "provider", "unknown")) + model = str(getattr(cfg, "model", "unknown")) + meta = getattr(self, "_last_llm_meta", {}) + prompt_tokens = int(meta.get("prompt_tokens", 0)) if isinstance(meta, dict) else 0 + completion_tokens = int(meta.get("completion_tokens", 0)) if isinstance(meta, dict) else 0 + total_tokens = int(meta.get("total_tokens", 0)) if isinstance(meta, dict) else 0 + endpoint = str(meta.get("endpoint", "unknown")) if isinstance(meta, dict) else "unknown" + prompt_preview = prompt[:120].replace("\n", " ") + phase = "answer" + lowered = prompt.lower() + if "analyze enterprise review intent" in lowered: + phase = "intent" + self.timing["intent_calls"] = int(self.timing["intent_calls"]) + 1 + self.timing["intent_time_s"] = float(self.timing["intent_time_s"]) + duration + elif "planning tool calls for answer generation" in lowered: + phase = "planner" + self.timing["planner_calls"] = int(self.timing["planner_calls"]) + 1 + self.timing["planner_time_s"] = float(self.timing["planner_time_s"]) + duration + else: + self.timing["answer_calls"] = int(self.timing["answer_calls"]) + 1 + self.timing["answer_time_s"] = float(self.timing["answer_time_s"]) + duration + + self.timing["llm_calls_total"] = int(self.timing["llm_calls_total"]) + 1 + self.timing["llm_time_total_s"] = float(self.timing["llm_time_total_s"]) + duration + self.timing["prompt_tokens_total"] = int(self.timing["prompt_tokens_total"]) + prompt_tokens + self.timing["completion_tokens_total"] = int(self.timing["completion_tokens_total"]) + completion_tokens + self.timing["tokens_total"] = int(self.timing["tokens_total"]) + total_tokens + llm_calls = self.timing["llm_calls"] + assert isinstance(llm_calls, list) + llm_calls.append( + { + "phase": phase, + "provider": provider, + "model": model, + "endpoint": endpoint, + "duration_s": round(duration, 3), + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": total_tokens, + "prompt_preview": prompt_preview, + } + ) + return result + + def _generate_answer(self, prompt: str, task_outputs, req): # type: ignore[override,no-untyped-def] + started = time.perf_counter() + result = super()._generate_answer(prompt, task_outputs, req) + duration = time.perf_counter() - started + + cfg = self.config.answer_llm + if str(getattr(cfg, "provider", "none")) == "none": + return result + + provider = str(getattr(cfg, "provider", "unknown")) + model = str(getattr(cfg, "model", "unknown")) + meta = getattr(self, "_last_llm_meta", {}) + prompt_tokens = int(meta.get("prompt_tokens", 0)) if isinstance(meta, dict) else 0 + completion_tokens = int(meta.get("completion_tokens", 0)) if isinstance(meta, dict) else 0 + total_tokens = int(meta.get("total_tokens", 0)) if isinstance(meta, dict) else 0 + endpoint = str(meta.get("endpoint", "unknown")) if isinstance(meta, dict) else "unknown" + + self.timing["answer_calls"] = int(self.timing["answer_calls"]) + 1 + self.timing["answer_time_s"] = float(self.timing["answer_time_s"]) + duration + self.timing["llm_calls_total"] = int(self.timing["llm_calls_total"]) + 1 + self.timing["llm_time_total_s"] = float(self.timing["llm_time_total_s"]) + duration + self.timing["prompt_tokens_total"] = int(self.timing["prompt_tokens_total"]) + prompt_tokens + self.timing["completion_tokens_total"] = int(self.timing["completion_tokens_total"]) + completion_tokens + self.timing["tokens_total"] = int(self.timing["tokens_total"]) + total_tokens + + llm_calls = self.timing["llm_calls"] + assert isinstance(llm_calls, list) + llm_calls.append( + { + "phase": "answer", + "provider": provider, + "model": model, + "endpoint": endpoint, + "duration_s": round(duration, 3), + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": total_tokens, + "prompt_preview": prompt[:120].replace("\n", " "), + } + ) + return result + + +class StaticDemoRetriever: + def __init__(self) -> None: + self._rows = [ + RetrievalResult( + chunk=BaseChunk( + id="c-001", + text=( + "All electrical equipment must include grounding and emergency stop controls. " + "Periodic inspection interval is every 6 months." + ), + metadata={ + "source_type": "normative", + "document_id": "doc-safety-01", + "version_id": "v3", + "page": "12", + "clause_path": "5.2.3", + "valid_to": "2099-12-31", + }, + source_id="doc-safety-01", + chunk_index=0, + ), + score=0.94, + provider="static-demo", + ), + RetrievalResult( + chunk=BaseChunk( + id="c-002", + text=( + "Operator training is mandatory before commissioning. " + "A signed checklist must be stored in the project archive." + ), + metadata={ + "source_type": "process", + "document_id": "doc-safety-02", + "version_id": "v1", + "page": "4", + "clause_path": "2.1", + "valid_to": "2099-12-31", + }, + source_id="doc-safety-02", + chunk_index=0, + ), + score=0.89, + provider="static-demo", + ), + ] + + def retrieve( + self, + question: str, + top_k: int = 5, + filters: dict[str, object] | None = None, + ) -> list[RetrievalResult]: + _ = question, filters + return self._rows[:top_k] + + +def build_pipeline() -> TracingEnterprisePipeline: + config = EnterprisePipelineConfig( + intent_llm=EnterpriseIntentLLMConfig( + provider="openai", + model="Qwen/Qwen3-32B-AWQ", + openai_base_url="http://hppii:8000/v1", + openai_api_key="EMPTY", + temperature=0.0, + max_tokens=180, + enable_tools=False, + allowed_tools=["retrieve_context", "group_evidence", "score_risks", "build_attribution"], + max_tool_calls=3, + ), + answer_llm=EnterpriseAnswerLLMConfig( + provider="openai", + model="Qwen/Qwen3-32B-AWQ", + openai_base_url="http://hppii:8000/v1", + openai_api_key="EMPTY", + temperature=0.1, + max_tokens=900, + enable_tools=False, + allowed_tools=[ + "retrieve_context", + "group_evidence", + "score_risks", + "build_attribution", + "create_word_document", + "create_xlsx_table", + ], + max_tool_calls=5, + ), + ) + return TracingEnterprisePipeline(query_retriever=StaticDemoRetriever(), config=config) + + +def main() -> None: + pipeline = build_pipeline() + + questions = [ + "Выдели ключевые требования безопасности и дай краткое резюме.", + "Найди потенциальные риски несоответствия и предложи приоритетные действия.", + ] + + results = [] + prompt_sources: list[str] = [] + timings: list[dict[str, object]] = [] + for idx, question in enumerate(questions, start=1): + req = ReviewBundleRequest( + session_id=f"vllm-demo-{idx}", + query_text=question, + document_ids=["doc-safety-01", "doc-safety-02"], + department_principals=["safety-team", "qa-team"], + ) + result = pipeline.review_bundle(req) + results.append(result) + prompt_sources.append(pipeline.last_prompt_source) + timings.append(dict(pipeline.timing)) + + output_dir = Path("artifacts") + output_dir.mkdir(parents=True, exist_ok=True) + + report_lines = [] + for idx, item in enumerate(results, start=1): + report_lines.append(f"Question {idx}: {questions[idx - 1]}") + report_lines.append("Answer:") + report_lines.append(item.llm_answer) + report_lines.append("") + + word_res = pipeline.answer_tools.call( + name="create_word_document", + context=ToolContext(request_id="vllm-demo-artifacts"), + args={ + "filename": str(output_dir / "enterprise_review.docx"), + "title": "Enterprise Review (vLLM Demo)", + "paragraphs": report_lines, + }, + ) + + xlsx_rows: list[list[str]] = [] + for idx, item in enumerate(results, start=1): + xlsx_rows.append( + [ + str(idx), + questions[idx - 1], + " | ".join(task.value for task in item.executed_tasks), + item.llm_answer[:500], + ] + ) + + xlsx_res = pipeline.answer_tools.call( + name="create_xlsx_table", + context=ToolContext(request_id="vllm-demo-artifacts"), + args={ + "filename": str(output_dir / "enterprise_review.xlsx"), + "sheet_name": "Answers", + "headers": ["QuestionNo", "Question", "ExecutedTasks", "AnswerPreview"], + "rows": xlsx_rows, + }, + ) + + print("Processed questions:") + for idx, q in enumerate(questions, start=1): + print(f"{idx}. {q}") + + print("\nPrompt diagnostics:") + for idx, item in enumerate(results, start=1): + source = prompt_sources[idx - 1] + timing = timings[idx - 1] + print(f"Question {idx}: uses PromptOrchestrator = {source == 'prompt_orchestrator'}") + print(f"Prompt source: {source}") + print( + "Timing: " + f"review_bundle={float(timing['review_bundle_time_s']):.3f}s, " + f"llm_total={float(timing['llm_time_total_s']):.3f}s, " + f"llm_calls={int(timing['llm_calls_total'])}, " + f"intent={float(timing['intent_time_s']):.3f}s/{int(timing['intent_calls'])} calls, " + f"planner={float(timing['planner_time_s']):.3f}s/{int(timing['planner_calls'])} calls, " + f"answer={float(timing['answer_time_s']):.3f}s/{int(timing['answer_calls'])} calls, " + f"tokens={int(timing['tokens_total'])} " + f"(prompt={int(timing['prompt_tokens_total'])}, completion={int(timing['completion_tokens_total'])})" + ) + print("LLM calls:") + llm_calls = timing["llm_calls"] + assert isinstance(llm_calls, list) + for call in llm_calls: + if not isinstance(call, dict): + continue + print( + f" - phase={call.get('phase')} provider={call.get('provider')} " + f"model={call.get('model')} endpoint={call.get('endpoint')} " + f"duration={call.get('duration_s')}s tokens={call.get('total_tokens')} " + f"(prompt={call.get('prompt_tokens')}, completion={call.get('completion_tokens')})" + ) + print("Generated prompt:") + print(f"===== PROMPT_BEGIN_Q{idx} =====") + print(item.prompt) + print(f"===== PROMPT_END_Q{idx} =====") + print("-" * 100) + + total_review = sum(float(t["review_bundle_time_s"]) for t in timings) + total_llm = sum(float(t["llm_time_total_s"]) for t in timings) + total_calls = sum(int(t["llm_calls_total"]) for t in timings) + total_prompt_tokens = sum(int(t["prompt_tokens_total"]) for t in timings) + total_completion_tokens = sum(int(t["completion_tokens_total"]) for t in timings) + total_tokens = sum(int(t["tokens_total"]) for t in timings) + print("\nTiming summary:") + print(f"Total review_bundle time: {total_review:.3f}s") + print(f"Total LLM time: {total_llm:.3f}s") + print(f"Total LLM calls: {total_calls}") + print( + f"Total tokens: {total_tokens} " + f"(prompt={total_prompt_tokens}, completion={total_completion_tokens})" + ) + + print("\nArtifacts:") + print(f"DOCX -> {word_res}") + print(f"XLSX -> {xlsx_res}") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 2338979..0713ab1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ragflow_orchestrator" -version = "0.1.16" +version = "0.1.17" description = "Universal RAG orchestration layer with pluggable vector DB adapters" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" @@ -27,6 +27,7 @@ classifiers = [ ] dependencies = [ "mypy>=2.1.0", + "openai>=1.0.0", "psycopg[binary]>=3.1", "pydantic>=2.8,<3", "ruff>=0.15.19", diff --git a/requirements.txt b/requirements.txt index 1b250e1..aadcae0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ pytest>=8.2 ruff>=0.5.0 mypy>=1.10 pylint>=3.2 +openai>=1.0.0 psycopg[binary]>=3.1 beautifulsoup4>=4.12 jusText>=3.0.1 diff --git a/src/ragflow_orchestrator/__init__.py b/src/ragflow_orchestrator/__init__.py index 999d89e..ca8e5a3 100644 --- a/src/ragflow_orchestrator/__init__.py +++ b/src/ragflow_orchestrator/__init__.py @@ -1,6 +1,17 @@ from ragflow_orchestrator.config import ConfigStore, EmbeddingConfig, ModuleConfig, PipelineConfig, ProviderConfig from ragflow_orchestrator.context import DocChunk from ragflow_orchestrator.embedding import HashEmbedder, HFEmbedder, OllamaEmbedder, create_embedder +from ragflow_orchestrator.enterprise import ( + ACLAwareRetrieverAdapter, + EnterpriseAnswerLLMConfig, + EnterpriseIntentLLMConfig, + EnterprisePipeline, + EnterprisePipelineConfig, + ReviewBundleRequest, + ReviewBundleResult, + ReviewTask, + StaticRetrieverAdapter, +) from ragflow_orchestrator.evaluation import ( DatasetItem, RerankProfileConfig, @@ -96,6 +107,15 @@ "StrategyEvaluationReport", "evaluate_strategies", "evaluate_rerank_profiles", + "EnterprisePipeline", + "ACLAwareRetrieverAdapter", + "StaticRetrieverAdapter", + "EnterpriseIntentLLMConfig", + "EnterpriseAnswerLLMConfig", + "EnterprisePipelineConfig", + "ReviewBundleRequest", + "ReviewBundleResult", + "ReviewTask", "LanguageMode", "TemplatesConfig", "TemplateRunMetrics", diff --git a/src/ragflow_orchestrator/enterprise/__init__.py b/src/ragflow_orchestrator/enterprise/__init__.py new file mode 100644 index 0000000..338642b --- /dev/null +++ b/src/ragflow_orchestrator/enterprise/__init__.py @@ -0,0 +1,44 @@ +from ragflow_orchestrator.enterprise.adapters import ACLAwareRetrieverAdapter, StaticRetrieverAdapter +from ragflow_orchestrator.enterprise.config import ( + EnterpriseAnswerLLMConfig, + EnterpriseBundleConfig, + EnterpriseIntentLLMConfig, + EnterpriseLLMConfig, + EnterprisePipelineConfig, + EnterpriseRiskConfig, + EnterpriseToolingConfig, +) +from ragflow_orchestrator.enterprise.models import ( + EvidenceItem, + ReviewBundleRequest, + ReviewBundleResult, + ReviewTask, + RiskAssessmentResult, + RiskEntry, + TaskOutput, +) +from ragflow_orchestrator.enterprise.pipeline import EnterprisePipeline +from ragflow_orchestrator.enterprise.tools import EnterpriseToolRegistry, MCPToolProvider, ToolContext + +__all__ = [ + "ACLAwareRetrieverAdapter", + "StaticRetrieverAdapter", + "EnterpriseAnswerLLMConfig", + "EnterpriseBundleConfig", + "EnterpriseIntentLLMConfig", + "EnterpriseLLMConfig", + "EnterprisePipelineConfig", + "EnterpriseRiskConfig", + "EnterpriseToolingConfig", + "EvidenceItem", + "ReviewBundleRequest", + "ReviewBundleResult", + "ReviewTask", + "RiskAssessmentResult", + "RiskEntry", + "TaskOutput", + "EnterprisePipeline", + "EnterpriseToolRegistry", + "MCPToolProvider", + "ToolContext", +] diff --git a/src/ragflow_orchestrator/enterprise/adapters.py b/src/ragflow_orchestrator/enterprise/adapters.py new file mode 100644 index 0000000..711945d --- /dev/null +++ b/src/ragflow_orchestrator/enterprise/adapters.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from ragflow_orchestrator.models import RetrievalQuery, RetrievalResult +from ragflow_orchestrator.protocols import Embedder, RAGProvider + + +class ACLAwareRetrieverAdapter: + """Query retriever adapter with explicit ACL propagation into RetrievalQuery.""" + + def __init__( + self, + *, + provider: RAGProvider, + embedder: Embedder, + include_deleted: bool = False, + ) -> None: + self.provider = provider + self.embedder = embedder + self.include_deleted = include_deleted + + def retrieve( + self, + question: str, + top_k: int = 5, + filters: dict[str, object] | None = None, + ) -> list[RetrievalResult]: + payload = dict(filters or {}) + acl_principals_raw = payload.pop("acl_principals", None) + include_deleted_raw = payload.pop("include_deleted", self.include_deleted) + + acl_principals: list[str] | None = None + if isinstance(acl_principals_raw, list): + acl_principals = [str(item) for item in acl_principals_raw] + + include_deleted = bool(include_deleted_raw) + + query = RetrievalQuery( + text_query=question, + top_k=top_k, + dense_vector=self.embedder.embed(question), + filters=[{"key": key, "value": value} for key, value in payload.items()], + include_deleted=include_deleted, + acl_principals=acl_principals, + ) + return self.provider.retrieve(query) + + +class StaticRetrieverAdapter: + """Small adapter useful for tests and offline simulations.""" + + def __init__(self, rows: list[RetrievalResult]) -> None: + self._rows = rows + + def retrieve( + self, + question: str, + top_k: int = 5, + filters: dict[str, object] | None = None, + ) -> list[RetrievalResult]: + _ = question, filters + return self._rows[:top_k] diff --git a/src/ragflow_orchestrator/enterprise/config.py b/src/ragflow_orchestrator/enterprise/config.py new file mode 100644 index 0000000..2384cd5 --- /dev/null +++ b/src/ragflow_orchestrator/enterprise/config.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + + +class EnterpriseLLMConfig(BaseModel): + provider: Literal["none", "openai", "ollama", "custom"] = "none" + model: str = "gpt-4o-mini" + max_tokens: int = 1000 + temperature: float = 0.1 + enable_tools: bool = False + max_tool_calls: int = 3 + allowed_tools: list[str] = Field(default_factory=list) + openai_api_key: str | None = None + openai_base_url: str | None = None + ollama_base_url: str = "http://localhost:11434" + timeout_seconds: int = 60 + + +class EnterpriseIntentLLMConfig(EnterpriseLLMConfig): + model: str = "gpt-4o-mini" + + +class EnterpriseAnswerLLMConfig(EnterpriseLLMConfig): + model: str = "gpt-4o-mini" + + +class EnterpriseRiskConfig(BaseModel): + enabled_by_default: bool = False + severity_weight: float = 0.5 + likelihood_weight: float = 0.3 + impact_weight: float = 0.2 + + +class EnterpriseToolingConfig(BaseModel): + enable_tools: bool = True + enable_mcp: bool = True + strict_tool_failures: bool = False + + +class EnterpriseBundleConfig(BaseModel): + default_tasks: list[str] = Field( + default_factory=lambda: [ + "requirements_extraction", + "compliance_check", + "consistency_validation", + "completeness_gap_analysis", + "ambiguity_precision_review", + ] + ) + max_evidence_total: int = 24 + max_evidence_per_group: int = 8 + top_k_per_retrieval: int = 8 + group_by_metadata_key: str = "source_type" + use_prompt_orchestrator: bool = True + + +class EnterprisePipelineConfig(BaseModel): + intent_llm: EnterpriseIntentLLMConfig = Field(default_factory=EnterpriseIntentLLMConfig) + answer_llm: EnterpriseAnswerLLMConfig = Field(default_factory=EnterpriseAnswerLLMConfig) + risk: EnterpriseRiskConfig = Field(default_factory=EnterpriseRiskConfig) + tooling: EnterpriseToolingConfig = Field(default_factory=EnterpriseToolingConfig) + bundle: EnterpriseBundleConfig = Field(default_factory=EnterpriseBundleConfig) diff --git a/src/ragflow_orchestrator/enterprise/models.py b/src/ragflow_orchestrator/enterprise/models.py new file mode 100644 index 0000000..1f32616 --- /dev/null +++ b/src/ragflow_orchestrator/enterprise/models.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from datetime import date +from enum import Enum + +from pydantic import BaseModel, Field + + +class ReviewTask(str, Enum): + REQUIREMENTS_EXTRACTION = "requirements_extraction" + COMPLIANCE_CHECK = "compliance_check" + CONSISTENCY_VALIDATION = "consistency_validation" + COMPLETENESS_GAP_ANALYSIS = "completeness_gap_analysis" + AMBIGUITY_PRECISION_REVIEW = "ambiguity_precision_review" + PROJECT_ERROR_ANALYSIS = "project_error_analysis" + CHANGE_IMPACT_DIFF = "change_impact_diff" + RISK_ASSESSMENT = "risk_assessment" + + +class ReviewBundleRequest(BaseModel): + session_id: str + query_text: str + document_ids: list[str] = Field(default_factory=list) + department_principals: list[str] = Field(default_factory=list) + as_of_date: date | None = None + requested_tasks: list[ReviewTask] = Field(default_factory=list) + enable_risk_assessment: bool | None = None + extra_context: dict[str, str] = Field(default_factory=dict) + + +class EvidenceItem(BaseModel): + id: str + group: str + content: str + score: float | None = None + attribution: str = "" + metadata: dict[str, str] = Field(default_factory=dict) + + +class TaskOutput(BaseModel): + task: ReviewTask + summary: str + findings: list[str] = Field(default_factory=list) + evidence_ids: list[str] = Field(default_factory=list) + + +class RiskEntry(BaseModel): + title: str + severity: str + likelihood: float + impact: float + confidence: float + priority_score: float + mitigation: str + evidence_ids: list[str] = Field(default_factory=list) + + +class RiskAssessmentResult(BaseModel): + enabled: bool + entries: list[RiskEntry] = Field(default_factory=list) + + +class ReviewBundleResult(BaseModel): + requested_tasks: list[ReviewTask] + executed_tasks: list[ReviewTask] + grouped_evidence: dict[str, list[EvidenceItem]] + task_outputs: list[TaskOutput] + risk_assessment: RiskAssessmentResult + prompt: str + llm_answer: str diff --git a/src/ragflow_orchestrator/enterprise/pipeline.py b/src/ragflow_orchestrator/enterprise/pipeline.py new file mode 100644 index 0000000..5d7b672 --- /dev/null +++ b/src/ragflow_orchestrator/enterprise/pipeline.py @@ -0,0 +1,923 @@ +from __future__ import annotations + +import importlib +import importlib.util +import json +from collections import defaultdict +from collections.abc import Sequence +from datetime import date, datetime +from pathlib import Path +from typing import Any, Protocol, cast +from urllib import request as urllib_request + +from ragflow_orchestrator.models import RetrievalResult +from ragflow_orchestrator.orchestrator import RAGOrchestrator + +from .adapters import ACLAwareRetrieverAdapter +from .config import EnterpriseLLMConfig, EnterprisePipelineConfig +from .models import ( + EvidenceItem, + ReviewBundleRequest, + ReviewBundleResult, + ReviewTask, + RiskAssessmentResult, + RiskEntry, + TaskOutput, +) +from .tools import EnterpriseToolRegistry, MCPToolProvider, ToolContext + + +class QueryRetriever(Protocol): + def retrieve( + self, + question: str, + top_k: int = 5, + filters: dict[str, object] | None = None, + ) -> list[RetrievalResult]: + ... + + +class EnterprisePipeline: + """Adaptive enterprise review bundle for document-centric RAG workflows.""" + + def __init__( + self, + *, + query_retriever: QueryRetriever, + config: EnterprisePipelineConfig | None = None, + mcp_provider: MCPToolProvider | None = None, + llm_client: object | None = None, + intent_llm_client: object | None = None, + answer_llm_client: object | None = None, + ) -> None: + self.query_retriever = query_retriever + self.config = config or EnterprisePipelineConfig() + self._mcp_provider = mcp_provider + self._intent_llm_client = intent_llm_client or llm_client + self._answer_llm_client = answer_llm_client or llm_client + self._prompt_orchestrator_available: bool | None = None + self._last_llm_meta: dict[str, Any] = {} + + self.tools = EnterpriseToolRegistry( + mcp_provider=mcp_provider, + enable_mcp=self.config.tooling.enable_mcp, + strict_failures=self.config.tooling.strict_tool_failures, + ) + self.intent_tools = EnterpriseToolRegistry( + mcp_provider=mcp_provider, + enable_mcp=self.config.tooling.enable_mcp, + strict_failures=self.config.tooling.strict_tool_failures, + ) + self.answer_tools = EnterpriseToolRegistry( + mcp_provider=mcp_provider, + enable_mcp=self.config.tooling.enable_mcp, + strict_failures=self.config.tooling.strict_tool_failures, + ) + self._register_default_tools() + + @classmethod + def from_orchestrator( + cls, + orchestrator: RAGOrchestrator, + *, + config: EnterprisePipelineConfig | None = None, + mcp_provider: MCPToolProvider | None = None, + llm_client: object | None = None, + intent_llm_client: object | None = None, + answer_llm_client: object | None = None, + ) -> EnterprisePipeline: + retriever = ACLAwareRetrieverAdapter( + provider=orchestrator.provider, + embedder=orchestrator.embedder, + ) + return cls( + query_retriever=retriever, + config=config, + mcp_provider=mcp_provider, + llm_client=llm_client, + intent_llm_client=intent_llm_client, + answer_llm_client=answer_llm_client, + ) + + def set_config(self, config: EnterprisePipelineConfig) -> None: + self.config = config + self.tools = EnterpriseToolRegistry( + mcp_provider=self._mcp_provider, + enable_mcp=self.config.tooling.enable_mcp, + strict_failures=self.config.tooling.strict_tool_failures, + ) + self.intent_tools = EnterpriseToolRegistry( + mcp_provider=self._mcp_provider, + enable_mcp=self.config.tooling.enable_mcp, + strict_failures=self.config.tooling.strict_tool_failures, + ) + self.answer_tools = EnterpriseToolRegistry( + mcp_provider=self._mcp_provider, + enable_mcp=self.config.tooling.enable_mcp, + strict_failures=self.config.tooling.strict_tool_failures, + ) + self._register_default_tools() + + def review_bundle(self, request_payload: ReviewBundleRequest) -> ReviewBundleResult: + tasks = self._resolve_tasks(request_payload) + evidence = self._retrieve_evidence(request_payload) + grouped = self._group_evidence(evidence) + + task_outputs = [self._execute_task(task, request_payload, grouped) for task in tasks] + + risk_enabled = ( + request_payload.enable_risk_assessment + if request_payload.enable_risk_assessment is not None + else self.config.risk.enabled_by_default + ) + risk_result = self._run_risk_assessment(task_outputs, grouped, enabled=risk_enabled) + + prompt = self._build_prompt(request_payload, tasks, grouped, task_outputs, risk_result) + llm_answer = self._generate_answer(prompt, task_outputs, request_payload) + + return ReviewBundleResult( + requested_tasks=tasks, + executed_tasks=tasks, + grouped_evidence=grouped, + task_outputs=task_outputs, + risk_assessment=risk_result, + prompt=prompt, + llm_answer=llm_answer, + ) + + def call_tool(self, name: str, request_id: str, args: dict[str, Any]) -> dict[str, Any]: + return self.tools.call(name=name, context=ToolContext(request_id=request_id), args=args) + + # --------------------------------------------------------------------- + # Internal orchestration + # --------------------------------------------------------------------- + def _resolve_tasks(self, req: ReviewBundleRequest) -> list[ReviewTask]: + if req.requested_tasks: + tasks = list(req.requested_tasks) + else: + llm_tasks = self._analyze_query_with_llm(req) + if llm_tasks: + tasks = llm_tasks + else: + tasks = [ReviewTask(item) for item in self.config.bundle.default_tasks] + lowered = req.query_text.lower() + if any(token in lowered for token in ("риск", "risk", "опас", "угроз")): + tasks.append(ReviewTask.RISK_ASSESSMENT) + if any(token in lowered for token in ("ошиб", "дефект", "problem", "issue")): + tasks.append(ReviewTask.PROJECT_ERROR_ANALYSIS) + if any(token in lowered for token in ("противореч", "consisten", "conflict")): + tasks.append(ReviewTask.CONSISTENCY_VALIDATION) + + seen: set[ReviewTask] = set() + unique: list[ReviewTask] = [] + for task in tasks: + if task in seen: + continue + seen.add(task) + unique.append(task) + return unique + + def _retrieve_evidence(self, req: ReviewBundleRequest) -> list[EvidenceItem]: + base_filters = self._compose_filters(req) + rows = self.query_retriever.retrieve( + question=req.query_text, + top_k=self.config.bundle.top_k_per_retrieval, + filters=base_filters, + ) + + out: list[EvidenceItem] = [] + for row in rows: + chunk = row.chunk + metadata_raw = chunk.metadata if isinstance(chunk.metadata, dict) else {} + metadata = {str(k): str(v) for k, v in metadata_raw.items()} + + if not self._is_document_allowed(req.document_ids, metadata): + continue + if not self._is_valid_on_date(req.as_of_date, metadata): + continue + + group = metadata.get(self.config.bundle.group_by_metadata_key) or "ungrouped" + out.append( + EvidenceItem( + id=chunk.id, + group=group, + content=chunk.text, + score=row.score, + attribution=self._build_attribution(metadata, chunk.id), + metadata=metadata, + ) + ) + + if len(out) > self.config.bundle.max_evidence_total: + out = sorted(out, key=lambda item: item.score or 0.0, reverse=True)[: self.config.bundle.max_evidence_total] + return out + + def _compose_filters(self, req: ReviewBundleRequest) -> dict[str, object]: + filters: dict[str, object] = {} + if req.department_principals: + filters["acl_principals"] = req.department_principals + filters.update(req.extra_context) + return filters + + def _group_evidence(self, evidence: list[EvidenceItem]) -> dict[str, list[EvidenceItem]]: + grouped: dict[str, list[EvidenceItem]] = defaultdict(list) + for item in evidence: + grouped[item.group].append(item) + for key in grouped: + grouped[key] = sorted(grouped[key], key=lambda x: x.score or 0.0, reverse=True)[: self.config.bundle.max_evidence_per_group] + return dict(grouped) + + def _execute_task( + self, + task: ReviewTask, + req: ReviewBundleRequest, + grouped: dict[str, list[EvidenceItem]], + ) -> TaskOutput: + findings: list[str] = [] + evidence_ids: list[str] = [] + + total = sum(len(items) for items in grouped.values()) + if total == 0: + return TaskOutput( + task=task, + summary="No evidence available for this task.", + findings=["No context chunks found after ACL/validity filtering."], + evidence_ids=[], + ) + + for group_name, items in grouped.items(): + if not items: + continue + evidence_ids.extend(item.id for item in items) + top = items[0] + if task == ReviewTask.REQUIREMENTS_EXTRACTION: + findings.append(f"[{group_name}] Candidate requirement sources identified ({len(items)} chunks).") + elif task == ReviewTask.COMPLIANCE_CHECK: + findings.append(f"[{group_name}] Compliance signals detected; top evidence score={top.score or 0.0:.3f}.") + elif task == ReviewTask.CONSISTENCY_VALIDATION: + findings.append(f"[{group_name}] Consistency check candidates extracted.") + elif task == ReviewTask.COMPLETENESS_GAP_ANALYSIS: + findings.append(f"[{group_name}] Potential coverage gaps to review manually.") + elif task == ReviewTask.AMBIGUITY_PRECISION_REVIEW: + findings.append(f"[{group_name}] Ambiguous or weakly specified statements may exist.") + elif task == ReviewTask.PROJECT_ERROR_ANALYSIS: + findings.append(f"[{group_name}] Potential project mistakes flagged for review.") + elif task == ReviewTask.CHANGE_IMPACT_DIFF: + findings.append(f"[{group_name}] Impact analysis requires baseline/version comparison input.") + elif task == ReviewTask.RISK_ASSESSMENT: + findings.append(f"[{group_name}] Risk indicators collected for scoring.") + + summary = f"Task {task.value} processed with {len(evidence_ids)} evidence items." + return TaskOutput(task=task, summary=summary, findings=findings, evidence_ids=sorted(set(evidence_ids))) + + def _run_risk_assessment( + self, + task_outputs: list[TaskOutput], + grouped: dict[str, list[EvidenceItem]], + *, + enabled: bool, + ) -> RiskAssessmentResult: + if not enabled: + return RiskAssessmentResult(enabled=False, entries=[]) + + risk_signals = 0 + evidence_ids: list[str] = [] + for task in task_outputs: + if task.task in { + ReviewTask.COMPLIANCE_CHECK, + ReviewTask.CONSISTENCY_VALIDATION, + ReviewTask.PROJECT_ERROR_ANALYSIS, + ReviewTask.COMPLETENESS_GAP_ANALYSIS, + }: + risk_signals += max(1, len(task.findings)) + evidence_ids.extend(task.evidence_ids) + + severity = "low" + if risk_signals >= 8: + severity = "high" + elif risk_signals >= 4: + severity = "medium" + + likelihood = min(1.0, 0.2 + (risk_signals * 0.08)) + impact = min(1.0, 0.3 + (len(grouped) * 0.1)) + confidence = min(1.0, 0.5 + (len(evidence_ids) * 0.01)) + + score = ( + self.config.risk.severity_weight * (1.0 if severity == "high" else (0.7 if severity == "medium" else 0.4)) + + self.config.risk.likelihood_weight * likelihood + + self.config.risk.impact_weight * impact + ) + + entry = RiskEntry( + title="Composite document review risk", + severity=severity, + likelihood=round(likelihood, 3), + impact=round(impact, 3), + confidence=round(confidence, 3), + priority_score=round(score, 3), + mitigation="Review top mismatches and update missing/ambiguous requirements before approval.", + evidence_ids=sorted(set(evidence_ids))[:20], + ) + return RiskAssessmentResult(enabled=True, entries=[entry]) + + def _build_prompt( + self, + req: ReviewBundleRequest, + tasks: list[ReviewTask], + grouped: dict[str, list[EvidenceItem]], + task_outputs: list[TaskOutput], + risk_result: RiskAssessmentResult, + ) -> str: + if self.config.bundle.use_prompt_orchestrator and self._check_prompt_orchestrator_available(): + built = self._build_prompt_via_prompt_orchestrator(req=req, grouped=grouped) + if built: + return built + + lines: list[str] = [] + lines.append("You are an enterprise document reviewer. Use evidence only.") + lines.append(f"User request: {req.query_text}") + lines.append(f"Tasks: {', '.join(task.value for task in tasks)}") + lines.append("") + + lines.append("Evidence by group:") + for group_name, items in grouped.items(): + lines.append(f"- Group: {group_name} ({len(items)} items)") + for item in items: + score = f"{item.score:.3f}" if item.score is not None else "n/a" + lines.append(f" - [{item.id}] score={score} :: {item.attribution}") + lines.append(f" {item.content[:400]}") + + lines.append("") + lines.append("Intermediate findings:") + for out in task_outputs: + lines.append(f"- {out.task.value}: {out.summary}") + for finding in out.findings[:4]: + lines.append(f" - {finding}") + + if risk_result.enabled and risk_result.entries: + lines.append("") + lines.append("Risk assessment:") + for risk in risk_result.entries: + lines.append( + f"- {risk.title}: severity={risk.severity}, likelihood={risk.likelihood}, impact={risk.impact}, priority={risk.priority_score}" + ) + + lines.append("") + lines.append("Return a structured answer with sections: Summary, Findings, Risks, Recommended actions, Citations.") + return "\n".join(lines) + + def _build_prompt_via_prompt_orchestrator( + self, + *, + req: ReviewBundleRequest, + grouped: dict[str, list[EvidenceItem]], + ) -> str | None: + try: + prompt_module = importlib.import_module("prompt_orchestrator") + state_module = importlib.import_module("prompt_orchestrator.context.state") + except Exception: + return None + + PromptBuilder = getattr(prompt_module, "PromptBuilder", None) + PromptConfig = getattr(prompt_module, "PromptConfig", None) + PromptContextState = getattr(prompt_module, "PromptContextState", None) + DocChunk = getattr(state_module, "DocChunk", None) + if not all((PromptBuilder, PromptConfig, PromptContextState, DocChunk)): + return None + + PromptBuilderCls = cast(type[Any], PromptBuilder) + PromptConfigCls = cast(type[Any], PromptConfig) + PromptContextStateCls = cast(type[Any], PromptContextState) + DocChunkCls = cast(type[Any], DocChunk) + + rag_chunks: list[Any] = [] + for group_name, items in grouped.items(): + for item in items: + rag_chunks.append( + DocChunkCls( + id=item.id, + content=f"[{group_name}] {item.content}\nSource: {item.attribution}", + score=item.score, + metadata=item.metadata, + ) + ) + + cfg = PromptConfigCls( + system_prompt="You are an enterprise document reviewer.", + role="Compliance and Risk Analyst", + task="Use only provided evidence and include citations for every substantial claim.", + constraints=[ + "Do not invent facts.", + "Always include citation IDs in findings.", + "Highlight uncertainty if evidence is insufficient.", + ], + output_format="Markdown", + examples=[], + ) + + state = PromptContextStateCls(session_id=req.session_id, summary=None, recent_messages=[], rag_chunks=rag_chunks) + builder = PromptBuilderCls() + sections = builder.build_sections(config=cfg, state=state, user_message=req.query_text, include_headers=False) + return "\n\n".join([sections["static"], sections["summary"], sections["recent"], sections["rag"]]) + + def _generate_answer(self, prompt: str, task_outputs: list[TaskOutput], req: ReviewBundleRequest) -> str: + cfg = self.config.answer_llm + provider = cfg.provider + if provider == "none": + merged = [f"{item.task.value}: {item.summary}" for item in task_outputs] + return "\n".join(merged) if merged else "No findings." + + tool_results: list[dict[str, Any]] = [] + if cfg.enable_tools: + tool_results = self._plan_and_execute_answer_tools(req=req, base_prompt=prompt) + if tool_results: + prompt = f"{prompt}\n\nTool execution results:\n{json.dumps(tool_results, ensure_ascii=False, indent=2)}" + + if provider == "custom": + client = self._answer_llm_client + if client is None: + return "Custom LLM provider selected, but no client was supplied." + text = client.generate( + prompt=prompt, + model=cfg.model, + max_tokens=cfg.max_tokens, + temperature=cfg.temperature, + ) + return str(text).strip() + + if provider == "openai": + return self._generate_openai(prompt, cfg) + if provider == "ollama": + return self._generate_ollama(prompt, cfg) + + return "Unsupported LLM provider configuration." + + # --------------------------------------------------------------------- + # LLM providers + # --------------------------------------------------------------------- + def _generate_openai(self, prompt: str, cfg: EnterpriseLLMConfig) -> str: + self._last_llm_meta = {} + try: + from openai import OpenAI # type: ignore[import-not-found] + except ImportError: + return "OpenAI client is not installed." + + http_client = None + try: + import httpx # type: ignore[import-not-found] + + # Avoid inherited proxy settings that can break local/private vLLM endpoints. + http_client = httpx.Client(timeout=cfg.timeout_seconds, trust_env=False) + except Exception: + http_client = None + + client = OpenAI( + api_key=cfg.openai_api_key, + base_url=cfg.openai_base_url, + http_client=http_client, + ) + + try: + chat = client.chat.completions.create( + model=cfg.model, + messages=[{"role": "user", "content": prompt}], + temperature=cfg.temperature, + max_tokens=cfg.max_tokens, + ) + chat_usage = getattr(chat, "usage", None) + self._last_llm_meta = { + "endpoint": "chat.completions", + "prompt_tokens": int(getattr(chat_usage, "prompt_tokens", 0) or 0), + "completion_tokens": int(getattr(chat_usage, "completion_tokens", 0) or 0), + "total_tokens": int(getattr(chat_usage, "total_tokens", 0) or 0), + } + return str(chat.choices[0].message.content or "").strip() + except Exception: + pass + + response = client.responses.create( + model=cfg.model, + input=prompt, + temperature=cfg.temperature, + max_output_tokens=cfg.max_tokens, + ) + usage_obj = getattr(response, "usage", None) + self._last_llm_meta = { + "endpoint": "responses", + "prompt_tokens": int(getattr(usage_obj, "prompt_tokens", 0) or 0), + "completion_tokens": int(getattr(usage_obj, "completion_tokens", 0) or 0), + "total_tokens": int(getattr(usage_obj, "total_tokens", 0) or 0), + } + text = getattr(response, "output_text", None) + if text: + return str(text).strip() + return "" + + def _generate_ollama(self, prompt: str, cfg: EnterpriseLLMConfig) -> str: + endpoint = f"{cfg.ollama_base_url.rstrip('/')}/api/generate" + payload = { + "model": cfg.model, + "prompt": prompt, + "stream": False, + "options": { + "temperature": cfg.temperature, + "num_predict": cfg.max_tokens, + }, + } + req = urllib_request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib_request.urlopen(req, timeout=cfg.timeout_seconds) as resp: + body = resp.read().decode("utf-8") + parsed = json.loads(body) + return str(parsed.get("response", "")).strip() + except Exception as exc: + return f"Ollama generation failed: {exc}" + + def _analyze_query_with_llm(self, req: ReviewBundleRequest) -> list[ReviewTask] | None: + cfg = self.config.intent_llm + if cfg.provider == "none": + return None + + available = [task.value for task in ReviewTask] + prompt = ( + "Analyze enterprise review intent and return strict JSON with keys: " + "tasks (list[str]), tool_calls (list[object]), notes (string). " + f"Allowed tasks: {available}. " + "Return JSON only.\n" + f"User query: {req.query_text}\n" + f"Extra context: {json.dumps(req.extra_context, ensure_ascii=False)}" + ) + + raw = self._call_llm_text(prompt=prompt, cfg=cfg, custom_client=self._intent_llm_client) + parsed = self._safe_json_load(raw) + tool_calls = self._extract_tool_calls(parsed) + if cfg.enable_tools and tool_calls: + tool_results = self._execute_tool_calls( + registry=self.intent_tools, + request_id=req.session_id, + tool_calls=tool_calls, + cfg=cfg, + ) + retry_prompt = ( + f"{prompt}\n\nTool results:\n{json.dumps(tool_results, ensure_ascii=False, indent=2)}\n" + "Using tool results, return final strict JSON only with keys tasks, tool_calls, notes." + ) + raw = self._call_llm_text(prompt=retry_prompt, cfg=cfg, custom_client=self._intent_llm_client) + parsed = self._safe_json_load(raw) + + if not isinstance(parsed, dict): + return None + + candidate = parsed.get("tasks") + if not isinstance(candidate, list): + return None + + resolved: list[ReviewTask] = [] + for item in candidate: + try: + resolved.append(ReviewTask(str(item))) + except Exception: + continue + return resolved or None + + def _plan_and_execute_answer_tools(self, req: ReviewBundleRequest, base_prompt: str) -> list[dict[str, Any]]: + cfg = self.config.answer_llm + available = self.answer_tools.names() + if not available: + return [] + + planner_prompt = ( + "You are planning tool calls for answer generation. Return strict JSON with key tool_calls only. " + "Each item: {name: str, args: object}. Return JSON only.\n" + f"Available tools: {available}\n" + f"User request: {req.query_text}\n" + f"Prompt summary: {base_prompt[:1200]}" + ) + raw = self._call_llm_text(prompt=planner_prompt, cfg=cfg, custom_client=self._answer_llm_client) + parsed = self._safe_json_load(raw) + tool_calls = self._extract_tool_calls(parsed) + if not tool_calls: + return [] + return self._execute_tool_calls( + registry=self.answer_tools, + request_id=req.session_id, + tool_calls=tool_calls, + cfg=cfg, + ) + + def _call_llm_text(self, *, prompt: str, cfg: EnterpriseLLMConfig, custom_client: object | None) -> str: + if cfg.provider == "custom": + if custom_client is None: + return "" + text = custom_client.generate( + prompt=prompt, + model=cfg.model, + max_tokens=cfg.max_tokens, + temperature=cfg.temperature, + ) + return str(text).strip() + if cfg.provider == "openai": + return self._generate_openai(prompt, cfg) + if cfg.provider == "ollama": + return self._generate_ollama(prompt, cfg) + return "" + + @staticmethod + def _safe_json_load(raw: str) -> dict[str, Any] | list[Any] | None: + text = (raw or "").strip() + if not text: + return None + try: + return json.loads(text) + except Exception: + pass + + start = text.find("{") + end = text.rfind("}") + if start >= 0 and end > start: + try: + return json.loads(text[start : end + 1]) + except Exception: + return None + return None + + @staticmethod + def _extract_tool_calls(payload: dict[str, Any] | list[Any] | None) -> list[dict[str, Any]]: + if not isinstance(payload, dict): + return [] + calls = payload.get("tool_calls") + if not isinstance(calls, list): + return [] + + out: list[dict[str, Any]] = [] + for call in calls: + if not isinstance(call, dict): + continue + name = call.get("name") + args = call.get("args") + if not isinstance(name, str): + continue + if not isinstance(args, dict): + args = {} + out.append({"name": name, "args": args}) + return out + + def _execute_tool_calls( + self, + *, + registry: EnterpriseToolRegistry, + request_id: str, + tool_calls: Sequence[dict[str, Any]], + cfg: EnterpriseLLMConfig, + ) -> list[dict[str, Any]]: + allowed = set(cfg.allowed_tools) + limited = list(tool_calls)[: max(0, cfg.max_tool_calls)] + out: list[dict[str, Any]] = [] + for call in limited: + name = str(call.get("name") or "") + args = call.get("args") + if not isinstance(args, dict): + args = {} + if allowed and name not in allowed: + out.append({"ok": False, "tool": name, "error": "Tool is not in allowed_tools"}) + continue + res = registry.call(name=name, context=ToolContext(request_id=request_id), args=args) + out.append({"tool": name, "result": res}) + return out + + # --------------------------------------------------------------------- + # Utility filters and formatting + # --------------------------------------------------------------------- + @staticmethod + def _build_attribution(metadata: dict[str, str], chunk_id: str) -> str: + doc = metadata.get("document_id") or metadata.get("doc_id") or "unknown-doc" + version = metadata.get("version_id") or metadata.get("version") or "unknown-version" + page = metadata.get("page") or metadata.get("page_number") or "?" + clause = metadata.get("clause_path") or metadata.get("standard_ref") or "" + valid_to = metadata.get("valid_to") or "" + parts = [f"doc={doc}", f"version={version}", f"page={page}", f"chunk={chunk_id}"] + if clause: + parts.append(f"clause={clause}") + if valid_to: + parts.append(f"valid_to={valid_to}") + return ", ".join(parts) + + @staticmethod + def _is_document_allowed(document_ids: list[str], metadata: dict[str, str]) -> bool: + if not document_ids: + return True + doc_id = metadata.get("document_id") or metadata.get("doc_id") + source_id = metadata.get("source_id") + return bool(doc_id and doc_id in document_ids) or bool(source_id and source_id in document_ids) + + @staticmethod + def _is_valid_on_date(as_of_date: date | None, metadata: dict[str, str]) -> bool: + if as_of_date is None: + return True + raw = (metadata.get("valid_to") or "").strip() + if not raw: + return True + + # Accept common date strings and ISO datetime. + candidates = [raw] + if "T" in raw: + candidates.append(raw.split("T", 1)[0]) + + for item in candidates: + try: + valid_to = datetime.fromisoformat(item).date() + return valid_to >= as_of_date + except Exception: + continue + return True + + def _check_prompt_orchestrator_available(self) -> bool: + if self._prompt_orchestrator_available is not None: + return self._prompt_orchestrator_available + try: + self._prompt_orchestrator_available = importlib.util.find_spec("prompt_orchestrator") is not None + except Exception: + self._prompt_orchestrator_available = False + return self._prompt_orchestrator_available + + def _register_default_tools(self) -> None: + if not self.config.tooling.enable_tools: + return + + self.tools.register("retrieve_context", self._tool_retrieve_context) + self.tools.register("group_evidence", self._tool_group_evidence) + self.tools.register("score_risks", self._tool_score_risks) + self.tools.register("build_attribution", self._tool_build_attribution) + + self.intent_tools.register("retrieve_context", self._tool_retrieve_context) + self.intent_tools.register("group_evidence", self._tool_group_evidence) + self.intent_tools.register("score_risks", self._tool_score_risks) + self.intent_tools.register("build_attribution", self._tool_build_attribution) + + self.answer_tools.register("retrieve_context", self._tool_retrieve_context) + self.answer_tools.register("group_evidence", self._tool_group_evidence) + self.answer_tools.register("score_risks", self._tool_score_risks) + self.answer_tools.register("build_attribution", self._tool_build_attribution) + self.answer_tools.register("create_word_document", self._tool_create_word_document) + self.answer_tools.register("create_xlsx_table", self._tool_create_xlsx_table) + + # --------------------------------------------------------------------- + # Default tools + # --------------------------------------------------------------------- + def _tool_retrieve_context(self, context: ToolContext, args: dict[str, Any]) -> dict[str, Any]: + question = str(args.get("question") or "") + top_k = int(args.get("top_k") or self.config.bundle.top_k_per_retrieval) + filters = args.get("filters") + if not isinstance(filters, dict): + filters = None + + rows = self.query_retriever.retrieve(question=question, top_k=top_k, filters=filters) + return { + "ok": True, + "request_id": context.request_id, + "count": len(rows), + "items": [ + { + "id": row.chunk.id, + "score": row.score, + "content": row.chunk.text, + "metadata": row.chunk.metadata, + } + for row in rows + ], + } + + def _tool_group_evidence(self, context: ToolContext, args: dict[str, Any]) -> dict[str, Any]: + items = args.get("items") + key = str(args.get("key") or self.config.bundle.group_by_metadata_key) + if not isinstance(items, list): + return {"ok": False, "request_id": context.request_id, "error": "items must be a list"} + + grouped: dict[str, int] = defaultdict(int) + for item in items: + if not isinstance(item, dict): + continue + metadata = item.get("metadata") + if isinstance(metadata, dict): + group = str(metadata.get(key) or "ungrouped") + else: + group = "ungrouped" + grouped[group] += 1 + + return {"ok": True, "request_id": context.request_id, "groups": dict(grouped)} + + def _tool_score_risks(self, context: ToolContext, args: dict[str, Any]) -> dict[str, Any]: + severity = str(args.get("severity") or "medium").lower() + likelihood = float(args.get("likelihood") or 0.5) + impact = float(args.get("impact") or 0.5) + severity_value = 1.0 if severity == "high" else (0.7 if severity == "medium" else 0.4) + score = ( + self.config.risk.severity_weight * severity_value + + self.config.risk.likelihood_weight * likelihood + + self.config.risk.impact_weight * impact + ) + return { + "ok": True, + "request_id": context.request_id, + "priority_score": round(score, 3), + } + + def _tool_build_attribution(self, context: ToolContext, args: dict[str, Any]) -> dict[str, Any]: + metadata = args.get("metadata") + chunk_id = str(args.get("chunk_id") or "unknown") + if not isinstance(metadata, dict): + return {"ok": False, "request_id": context.request_id, "error": "metadata must be a dict"} + + text = self._build_attribution({str(k): str(v) for k, v in metadata.items()}, chunk_id) + return {"ok": True, "request_id": context.request_id, "attribution": text} + + def _tool_create_word_document(self, context: ToolContext, args: dict[str, Any]) -> dict[str, Any]: + filename = str(args.get("filename") or f"review-{context.request_id}.docx") + title = str(args.get("title") or "Review Report") + content = str(args.get("content") or "") + paragraphs_raw = args.get("paragraphs") + paragraphs: list[str] = [] + if isinstance(paragraphs_raw, list): + paragraphs = [str(item) for item in paragraphs_raw] + + try: + from docx import Document # type: ignore[import-not-found] + except ImportError: + return { + "ok": False, + "request_id": context.request_id, + "error": "python-docx is not installed", + } + + path = Path(filename) + if not path.is_absolute(): + path = Path.cwd() / path + path.parent.mkdir(parents=True, exist_ok=True) + + doc = Document() + doc.add_heading(title, level=1) + if content: + doc.add_paragraph(content) + for paragraph in paragraphs: + doc.add_paragraph(paragraph) + doc.save(str(path)) + + return { + "ok": True, + "request_id": context.request_id, + "path": str(path), + "bytes": path.stat().st_size, + } + + def _tool_create_xlsx_table(self, context: ToolContext, args: dict[str, Any]) -> dict[str, Any]: + filename = str(args.get("filename") or f"review-{context.request_id}.xlsx") + sheet_name = str(args.get("sheet_name") or "Review") + headers_raw = args.get("headers") + rows_raw = args.get("rows") + + headers: list[str] = [] + if isinstance(headers_raw, list): + headers = [str(item) for item in headers_raw] + + rows: list[list[str]] = [] + if isinstance(rows_raw, list): + for row in rows_raw: + if isinstance(row, list): + rows.append([str(item) for item in row]) + + try: + from openpyxl import Workbook # type: ignore[import-not-found] + except ImportError: + return { + "ok": False, + "request_id": context.request_id, + "error": "openpyxl is not installed", + } + + path = Path(filename) + if not path.is_absolute(): + path = Path.cwd() / path + path.parent.mkdir(parents=True, exist_ok=True) + + workbook = Workbook() + worksheet = workbook.active + worksheet.title = sheet_name[:31] or "Review" + + if headers: + worksheet.append(headers) + for row in rows: + worksheet.append(row) + + workbook.save(str(path)) + return { + "ok": True, + "request_id": context.request_id, + "path": str(path), + "rows": len(rows), + "bytes": path.stat().st_size, + } diff --git a/src/ragflow_orchestrator/enterprise/tools.py b/src/ragflow_orchestrator/enterprise/tools.py new file mode 100644 index 0000000..9e63394 --- /dev/null +++ b/src/ragflow_orchestrator/enterprise/tools.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Protocol + + +class MCPToolProvider(Protocol): + def list_tools(self) -> list[str]: + ... + + def call_tool(self, name: str, args: dict[str, Any]) -> dict[str, Any]: + ... + + +@dataclass(slots=True) +class ToolContext: + request_id: str + + +ToolCallable = Callable[[ToolContext, dict[str, Any]], dict[str, Any]] + + +class EnterpriseToolRegistry: + def __init__( + self, + *, + mcp_provider: MCPToolProvider | None = None, + enable_mcp: bool = True, + strict_failures: bool = False, + ) -> None: + self._tools: dict[str, ToolCallable] = {} + self._mcp_provider = mcp_provider + self._enable_mcp = enable_mcp + self._strict_failures = strict_failures + + def register(self, name: str, tool: ToolCallable) -> None: + self._tools[name] = tool + + def names(self) -> list[str]: + names = list(self._tools) + if self._mcp_provider is not None and self._enable_mcp: + try: + names.extend(self._mcp_provider.list_tools()) + except Exception: + if self._strict_failures: + raise + return sorted(set(names)) + + def call(self, name: str, context: ToolContext, args: dict[str, Any]) -> dict[str, Any]: + local = self._tools.get(name) + if local is not None: + try: + return local(context, args) + except Exception as exc: + if self._strict_failures: + raise + return {"ok": False, "error": str(exc), "tool": name} + + if self._mcp_provider is not None and self._enable_mcp: + try: + return self._mcp_provider.call_tool(name, args) + except Exception as exc: + if self._strict_failures: + raise + return {"ok": False, "error": str(exc), "tool": name} + + return {"ok": False, "error": f"Tool '{name}' is not registered", "tool": name} diff --git a/tests/test_enterprise_pipeline.py b/tests/test_enterprise_pipeline.py new file mode 100644 index 0000000..d90de09 --- /dev/null +++ b/tests/test_enterprise_pipeline.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +from datetime import date + +from ragflow_orchestrator.enterprise import ( + ACLAwareRetrieverAdapter, + EnterpriseAnswerLLMConfig, + EnterpriseIntentLLMConfig, + EnterprisePipeline, + EnterprisePipelineConfig, + ReviewBundleRequest, + ReviewTask, +) +from ragflow_orchestrator.models import BaseChunk, RetrievalResult +from ragflow_orchestrator.orchestrator import RAGOrchestrator +from ragflow_orchestrator.presets import document_preset + + +class DummyRetriever: + def retrieve(self, question: str, top_k: int = 5, filters: dict[str, object] | None = None) -> list[RetrievalResult]: + _ = question, top_k, filters + chunk = BaseChunk( + id="c1", + text="Document clause says equipment must have grounding and emergency stop.", + metadata={ + "source_type": "normative", + "document_id": "doc-1", + "version_id": "v1", + "page": "12", + "clause_path": "5.2.3", + "valid_to": "2099-12-31", + }, + source_id="doc-1", + chunk_index=0, + ) + return [RetrievalResult(chunk=chunk, score=0.91, provider="dummy")] + + +class DummyProvider: + name = "dummy" + + def ensure_schema(self, vector_dim: int) -> None: + _ = vector_dim + + def upsert_chunks(self, chunks: list[BaseChunk]) -> None: + _ = chunks + + def delete_chunks(self, chunk_ids: list[str], soft_delete: bool = True) -> None: + _ = chunk_ids, soft_delete + + def retrieve(self, query): + _ = query + chunk = BaseChunk(id="d1", text="dummy", metadata={}, source_id="d", chunk_index=0) + return [RetrievalResult(chunk=chunk, score=0.5, provider=self.name)] + + def healthcheck(self) -> bool: + return True + + +class DummyEmbedder: + dimensions = 4 + + def embed(self, text: str) -> list[float]: + _ = text + return [0.1, 0.2, 0.3, 0.4] + + def embed_many(self, texts): + return [self.embed(text) for text in texts] + + +class IntentLLMStub: + def generate(self, prompt: str, model: str, max_tokens: int, temperature: float) -> str: + _ = prompt, model, max_tokens, temperature + return '{"tasks": ["risk_assessment", "project_error_analysis"], "tool_calls": [], "notes": "ok"}' + + +class AnswerLLMStub: + def __init__(self) -> None: + self.prompts: list[str] = [] + + def generate(self, prompt: str, model: str, max_tokens: int, temperature: float) -> str: + _ = model, max_tokens, temperature + self.prompts.append(prompt) + if "planning tool calls for answer generation" in prompt.lower(): + return ( + '{"tool_calls": [' + '{"name": "score_risks", "args": {"severity": "high", "likelihood": 0.9, "impact": 0.8}}' + "]}" + ) + return "Generated answer" + + +def test_review_bundle_runs_with_defaults() -> None: + pipeline = EnterprisePipeline(query_retriever=DummyRetriever()) + result = pipeline.review_bundle( + ReviewBundleRequest( + session_id="s1", + query_text="Check compliance and find possible issues", + department_principals=["dept_a"], + as_of_date=date.today(), + document_ids=["doc-1"], + ) + ) + + assert result.executed_tasks + assert result.grouped_evidence + assert result.prompt + assert result.llm_answer + + +def test_review_bundle_respects_optional_risk_flag() -> None: + pipeline = EnterprisePipeline(query_retriever=DummyRetriever()) + result = pipeline.review_bundle( + ReviewBundleRequest( + session_id="s2", + query_text="Assess requirements", + requested_tasks=[ReviewTask.REQUIREMENTS_EXTRACTION], + enable_risk_assessment=False, + ) + ) + + assert result.risk_assessment.enabled is False + assert result.risk_assessment.entries == [] + + +def test_acl_aware_adapter_forwards_acl_principals() -> None: + captured = {} + + class CapturingProvider(DummyProvider): + def retrieve(self, query): + captured["acl_principals"] = query.acl_principals + return super().retrieve(query) + + adapter = ACLAwareRetrieverAdapter(provider=CapturingProvider(), embedder=DummyEmbedder()) + adapter.retrieve("hello", top_k=3, filters={"acl_principals": ["dept_a", "dept_b"], "source_type": "normative"}) + + assert captured["acl_principals"] == ["dept_a", "dept_b"] + + +def test_from_orchestrator_constructor_and_tool_call() -> None: + preset = document_preset() + orchestrator = RAGOrchestrator( + provider=DummyProvider(), + embedder=DummyEmbedder(), + chunker=preset.chunker, + cleaner=preset.cleaner, + ) + pipeline = EnterprisePipeline.from_orchestrator( + orchestrator, + config=EnterprisePipelineConfig(), + ) + + response = pipeline.call_tool( + name="score_risks", + request_id="req-1", + args={"severity": "high", "likelihood": 0.9, "impact": 0.8}, + ) + + assert response["ok"] is True + assert response["priority_score"] > 0.0 + + +def test_llm_intent_analysis_selects_tasks() -> None: + config = EnterprisePipelineConfig( + intent_llm=EnterpriseIntentLLMConfig(provider="custom"), + answer_llm=EnterpriseAnswerLLMConfig(provider="none"), + ) + pipeline = EnterprisePipeline( + query_retriever=DummyRetriever(), + config=config, + intent_llm_client=IntentLLMStub(), + ) + + result = pipeline.review_bundle( + ReviewBundleRequest( + session_id="intent-1", + query_text="Проведи анализ", + requested_tasks=[], + ) + ) + + assert result.executed_tasks == [ReviewTask.RISK_ASSESSMENT, ReviewTask.PROJECT_ERROR_ANALYSIS] + + +def test_answer_llm_can_plan_and_use_tools() -> None: + answer_client = AnswerLLMStub() + config = EnterprisePipelineConfig( + intent_llm=EnterpriseIntentLLMConfig(provider="none"), + answer_llm=EnterpriseAnswerLLMConfig( + provider="custom", + enable_tools=True, + allowed_tools=["score_risks"], + max_tool_calls=1, + ), + ) + pipeline = EnterprisePipeline( + query_retriever=DummyRetriever(), + config=config, + answer_llm_client=answer_client, + ) + + result = pipeline.review_bundle( + ReviewBundleRequest( + session_id="ans-1", + query_text="Сделай вывод и приоритеты", + requested_tasks=[ReviewTask.COMPLIANCE_CHECK], + ) + ) + + assert result.llm_answer == "Generated answer" + assert len(answer_client.prompts) >= 2 + assert "Tool execution results" in answer_client.prompts[-1] From 72c0e6c151d7e30edb7ce52a3f6093dd746d3c59 Mon Sep 17 00:00:00 2001 From: Alexander Ivanov Date: Wed, 24 Jun 2026 17:00:45 +0300 Subject: [PATCH 2/2] minimal changes --- artifacts/enterprise_review.docx | Bin 37917 -> 37681 bytes artifacts/enterprise_review.xlsx | Bin 5922 -> 5898 bytes docker-compose.infra.yml | 2 +- pyproject.toml | 2 +- 4 files changed, 2 insertions(+), 2 deletions(-) diff --git a/artifacts/enterprise_review.docx b/artifacts/enterprise_review.docx index 8e07755223445760d985ed178e37f6c31063e0a4..502beea97f6a6032e3eaec380b0abda5e0dc9165 100644 GIT binary patch delta 3418 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 BR&xLV delta 3729 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 delta 1928 zcmY*adpy$%AO6j~t>rRXYh{S>#z~5~q(nD1bmF*eBUB01(q* z9STI|n8&ZPbm{cx>fjWy!%*p@mv0ZK7xF z=|HfbWq+jHT_XY~=aYF?B4<~2pYZDGOadiBn+Pq;(Tee$%Q3W%8|#|KXC0i8)}~sD zA=aTUnKr|&?BpHprCHtU{TSZ^0j#yurVE|ifkVF-1_JV+7+uCFldX=sWY4`QdJWC- zNqlkZBV;_Q*?zObV5j%2(0MW!(^Z4b!|9rDU-}|mt-VcYIc+PS5M+epiZVlb4f?A3 zrhY8OuVnN#zNO-VBZ?j0x)L@lTQ`YVJT?7bR;=eICXq(|bVEPl_K=H%v+&aE=pxGCy>L|B`h=|4Q?R`4XZ_TM}IKcc6wu^tW9=>Falic*hZ?sPtAU&UtUeRNY|-g%sLNurn<@Vv+}Q#%Hzt~ z-c9xpeG3#bEUuGuP)04Q=LQlp#9squo^(ES@tSh}t&N@nEi&I(ytbBHdZot|bfV#u z_{*An(^50TxW_Lp3$~>VzWb}l#l$}gE@!rubMx4BukWv~OnaAO2~OaV;_B$*D_U5* zVlZ*eZ}8jPmE4eFZi;|G8Bx+Xa}1qOnj1Z)n<_@ZpEciIZgXru`5St@PS3tVJ7~G(;xTh)z;@I^}?t2UL-72(zk@#_|rJl zFP!_Hi)MC4Sv#^F3^OeFMzn^KqlTK93ho|?a_$D)(7Y_SeI}tX#o>zd*BQ;5*`nXy zh;i>Zc2{cfAo37W9r&ghJpVWxyt*b*Yxq7MC&-Nf7gQbm z>f`Mj@N^iA9y4%YOgUv+)Z&YDa@D{6ORcTK)5n$)zTk5^A4QR6^gRmvx&O*}pv}{x z<3zbV6K?rcrLree8g{fge1x56jL9e6ltSjgv4{x*{oebuA;HRFbr?#7Y}N%h*Dg)c zG?@l{eI;PhQhIkFhir*HeB1Np4Al4}J|CV>zP|E3^$v^`u;jg4hKVg1K~sh~(#=pI z(zw97I+Buh)k(gjdVbbnsO+zk0kVLee2(Tq+szYup-xmktqZGTt3eT$*v&ph5W6?Y z2Q3Be%8V_TP984rh8q)2*y(g@d|QRj!%?XfRgjyX>G>n@JCbi3|C-Ut|2!z5wGXa_ zGlePI``dtSnd(z$k#51HuKPSA-%~ls*7C6VdmEk!TIH15d{_HXibf0($G~Zrxk_2h z7%wI*?SHgjYDYxK2>cI@^2s5Om(lZq-fdg4^`r@Gp_odME>@9sROl71+LDb2W&Q-o z*~f~egyWUCgqbZ|o8$+M7UCOnnmV88HmQ7E+gD6rdgY2|WIi;w2HbBGy= z`#cTt*cA;;dW+JJjwoKi4k@=|A0T)2j`&wIRjEBGm@bUl_pY*pe)gT*+@f8eSDf!x-pSw4i;o!s^5!w1l58={rpCZ4E5uANJnS70HMyPK(GeB z7F`_HX}{BKGBX=~MrdOgyD1|GazBgc?RupfU_bp4v0~&f;t;jNid?dKo-PEQC0gaN z^TEVM7jRsG>g5t!^CEJqtn^oH!4Fq($zHc3;;Yo$eII#1=B5M1xyS~LZ4~JoZm-#6 zsRB3L>doCBa!~EiXCRhXIdRPW&0ux%dPlNKeOw4LX|y&L6MJoB5kes$jE_^E z&rd3z;}xX}M5*H*b0(&>bB$}JPfSUBZ2Lz;7o)gC9h&!B9#hYpZ{P*<_UZ586>qfo zhIclT4QbtfmX}03ZtqrFz^}jc`bTBgJ%T4lL5S?*N+JlcZm+U0L_*jq(-IPiu=`2c zRS*Af-Pj69Q;B4Sw1WR%zq4;3%_VvlQteP58~~z%_JxH<2l<8>L{WoI+d-sMf&WaU zWE5_(T6aB?3@1Vg0AP~;-(x@=*%~N}#Kr=p206$kp|BDhCBcR4Y}6hJek8$(?3XAV Jx&EJaz~8?-hoJxf diff --git a/docker-compose.infra.yml b/docker-compose.infra.yml index 11fce9d..8c2c555 100644 --- a/docker-compose.infra.yml +++ b/docker-compose.infra.yml @@ -18,7 +18,7 @@ services: restart: unless-stopped pgadmin: - image: dpage/pgadmin4:8 + image: dpage/pgadmin4:9 container_name: rag-pgadmin environment: PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@example.com} diff --git a/pyproject.toml b/pyproject.toml index 0713ab1..e6e7fbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ragflow_orchestrator" -version = "0.1.17" +version = "0.1.18" description = "Universal RAG orchestration layer with pluggable vector DB adapters" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11"