pulse-engine 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pulse_engine/__init__.py +0 -0
- pulse_engine/adapters/__init__.py +58 -0
- pulse_engine/adapters/audio_transcription.py +167 -0
- pulse_engine/adapters/batcher.py +36 -0
- pulse_engine/adapters/digital_news.py +128 -0
- pulse_engine/adapters/digital_news_metadata.py +536 -0
- pulse_engine/adapters/exceptions.py +10 -0
- pulse_engine/adapters/models.py +134 -0
- pulse_engine/adapters/opensearch_storage.py +160 -0
- pulse_engine/adapters/speech_content.py +130 -0
- pulse_engine/adapters/speech_metadata.py +374 -0
- pulse_engine/adapters/twitter.py +423 -0
- pulse_engine/adapters/youtube_downloader.py +186 -0
- pulse_engine/adapters/youtube_metadata.py +261 -0
- pulse_engine/api/__init__.py +0 -0
- pulse_engine/api/v1/__init__.py +0 -0
- pulse_engine/api/v1/auth.py +91 -0
- pulse_engine/api/v1/health.py +62 -0
- pulse_engine/api/v1/router.py +16 -0
- pulse_engine/chain_recovery.py +131 -0
- pulse_engine/cli/__init__.py +0 -0
- pulse_engine/cli/main.py +169 -0
- pulse_engine/cli/templates/cookiecutter.json +4 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/.gitignore +13 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/Dockerfile +32 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/pipeline.yaml +17 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/pyproject.toml +25 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/src/pulse_{{cookiecutter.product_slug}}/__init__.py +8 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/__init__.py +0 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/unit/__init__.py +0 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/unit/test_manifest.py +15 -0
- pulse_engine/client.py +95 -0
- pulse_engine/config.py +157 -0
- pulse_engine/core/__init__.py +0 -0
- pulse_engine/core/error_handlers.py +64 -0
- pulse_engine/core/exceptions.py +67 -0
- pulse_engine/core/job_token.py +109 -0
- pulse_engine/core/logging.py +45 -0
- pulse_engine/core/scope.py +23 -0
- pulse_engine/core/security.py +130 -0
- pulse_engine/database.py +30 -0
- pulse_engine/dependencies.py +166 -0
- pulse_engine/deployment/__init__.py +0 -0
- pulse_engine/deployment/backend_deployment_repository.py +83 -0
- pulse_engine/deployment/backends/__init__.py +0 -0
- pulse_engine/deployment/backends/base.py +50 -0
- pulse_engine/deployment/backends/exceptions.py +20 -0
- pulse_engine/deployment/backends/native_lambda.py +125 -0
- pulse_engine/deployment/backends/prefect_ecs.py +116 -0
- pulse_engine/deployment/backends/prefect_k8s.py +131 -0
- pulse_engine/deployment/backends/registry.py +50 -0
- pulse_engine/deployment/infra_provisioner.py +285 -0
- pulse_engine/deployment/job_launcher.py +178 -0
- pulse_engine/deployment/models.py +48 -0
- pulse_engine/deployment/repository.py +54 -0
- pulse_engine/deployment/router.py +22 -0
- pulse_engine/deployment/schemas.py +18 -0
- pulse_engine/deployment/service.py +65 -0
- pulse_engine/extractor/__init__.py +0 -0
- pulse_engine/extractor/adapters/__init__.py +0 -0
- pulse_engine/extractor/base.py +48 -0
- pulse_engine/extractor/models.py +50 -0
- pulse_engine/extractor/orchestrator/__init__.py +15 -0
- pulse_engine/extractor/orchestrator/base.py +34 -0
- pulse_engine/extractor/orchestrator/noop.py +37 -0
- pulse_engine/extractor/orchestrator/prefect.py +163 -0
- pulse_engine/extractor/repository.py +163 -0
- pulse_engine/extractor/router.py +102 -0
- pulse_engine/extractor/schemas.py +93 -0
- pulse_engine/extractor/service.py +431 -0
- pulse_engine/extractor/stage_models.py +36 -0
- pulse_engine/extractor/stage_repository.py +109 -0
- pulse_engine/main.py +195 -0
- pulse_engine/mcp/__init__.py +0 -0
- pulse_engine/mcp/__main__.py +5 -0
- pulse_engine/mcp/server.py +108 -0
- pulse_engine/mcp/tools_jobs.py +159 -0
- pulse_engine/mcp/tools_kb.py +88 -0
- pulse_engine/mcp/tools_modules.py +115 -0
- pulse_engine/mcp/tools_pipelines.py +215 -0
- pulse_engine/mcp/tools_processor.py +208 -0
- pulse_engine/middleware/__init__.py +0 -0
- pulse_engine/middleware/rate_limit.py +144 -0
- pulse_engine/middleware/request_id.py +16 -0
- pulse_engine/middleware/security_headers.py +25 -0
- pulse_engine/middleware/tenant.py +90 -0
- pulse_engine/pipeline/__init__.py +0 -0
- pulse_engine/pipeline/config_parser.py +148 -0
- pulse_engine/pipeline/expression.py +268 -0
- pulse_engine/pipeline/models.py +98 -0
- pulse_engine/pipeline/repositories.py +224 -0
- pulse_engine/pipeline/router_modules.py +66 -0
- pulse_engine/pipeline/router_pipelines.py +198 -0
- pulse_engine/pipeline/schemas.py +200 -0
- pulse_engine/pipeline/service.py +250 -0
- pulse_engine/pipeline/translators/__init__.py +44 -0
- pulse_engine/pipeline/translators/airflow_status.py +11 -0
- pulse_engine/pipeline/translators/airflow_translator.py +22 -0
- pulse_engine/pipeline/translators/base.py +42 -0
- pulse_engine/pipeline/translators/prefect_status.py +93 -0
- pulse_engine/pipeline/translators/prefect_translator.py +195 -0
- pulse_engine/processor/__init__.py +0 -0
- pulse_engine/processor/base.py +36 -0
- pulse_engine/processor/core/__init__.py +0 -0
- pulse_engine/processor/core/analysis.py +148 -0
- pulse_engine/processor/core/chunking.py +158 -0
- pulse_engine/processor/core/prompts.py +340 -0
- pulse_engine/processor/core/topic_splitter.py +105 -0
- pulse_engine/processor/defaults/__init__.py +11 -0
- pulse_engine/processor/defaults/core_processor.py +12 -0
- pulse_engine/processor/defaults/postprocessor.py +12 -0
- pulse_engine/processor/defaults/preprocessor.py +12 -0
- pulse_engine/processor/llm/__init__.py +0 -0
- pulse_engine/processor/llm/provider.py +58 -0
- pulse_engine/processor/ocr/gemini.py +52 -0
- pulse_engine/processor/pipeline.py +107 -0
- pulse_engine/processor/postprocessor/__init__.py +0 -0
- pulse_engine/processor/postprocessor/embeddings.py +34 -0
- pulse_engine/processor/postprocessor/tasks.py +180 -0
- pulse_engine/processor/preprocessor/__init__.py +0 -0
- pulse_engine/processor/preprocessor/tasks.py +71 -0
- pulse_engine/processor/router.py +192 -0
- pulse_engine/processor/schemas.py +167 -0
- pulse_engine/registry.py +117 -0
- pulse_engine/runners/__init__.py +0 -0
- pulse_engine/runners/lambda_runner.py +26 -0
- pulse_engine/runners/pipeline_runner.py +43 -0
- pulse_engine/runners/prefect_pipeline_flow.py +904 -0
- pulse_engine/runners/prefect_runner.py +33 -0
- pulse_engine/s3.py +72 -0
- pulse_engine/secrets.py +46 -0
- pulse_engine/services/__init__.py +0 -0
- pulse_engine/services/bootstrap.py +211 -0
- pulse_engine/services/opensearch.py +84 -0
- pulse_engine/storage/__init__.py +0 -0
- pulse_engine/storage/connectors/__init__.py +0 -0
- pulse_engine/storage/connectors/athena.py +226 -0
- pulse_engine/storage/connectors/base.py +32 -0
- pulse_engine/storage/connectors/opensearch.py +344 -0
- pulse_engine/storage/knowledge_base.py +68 -0
- pulse_engine/storage/router.py +78 -0
- pulse_engine/storage/schemas.py +93 -0
- pulse_engine/testing/__init__.py +13 -0
- pulse_engine/testing/fixtures.py +50 -0
- pulse_engine/testing/mocks.py +104 -0
- pulse_engine/worker.py +53 -0
- pulse_engine-0.2.0.dist-info/METADATA +654 -0
- pulse_engine-0.2.0.dist-info/RECORD +150 -0
- pulse_engine-0.2.0.dist-info/WHEEL +4 -0
- pulse_engine-0.2.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from langchain_core.prompts import ChatPromptTemplate
|
|
6
|
+
|
|
7
|
+
from pulse_engine.processor.core.prompts import (
|
|
8
|
+
TRANSCRIPT_SPLITTER_EXAMPLES,
|
|
9
|
+
TRANSCRIPT_SPLITTER_PROMPT,
|
|
10
|
+
)
|
|
11
|
+
from pulse_engine.processor.llm.provider import get_llm
|
|
12
|
+
from pulse_engine.processor.schemas import TopicSplitResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TopicSplitter:
|
|
16
|
+
"""LLM-backed topic splitter that detects topic shift points.
|
|
17
|
+
|
|
18
|
+
Accepts either a plain string (split into paragraphs) or a list of
|
|
19
|
+
``(index, text)`` tuples. Returns a :class:`TopicSplitResult` with
|
|
20
|
+
the detected shift indexes and reasons.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
provider: str | None = None,
|
|
26
|
+
model: str | None = None,
|
|
27
|
+
api_key: str | None = None,
|
|
28
|
+
temperature: float | None = None,
|
|
29
|
+
interaction_title: str = "",
|
|
30
|
+
account: str = "",
|
|
31
|
+
client: str = "",
|
|
32
|
+
account_attendees: str = "",
|
|
33
|
+
client_attendees: str = "",
|
|
34
|
+
) -> None:
|
|
35
|
+
self._provider = provider
|
|
36
|
+
self._model = model
|
|
37
|
+
self._api_key = api_key
|
|
38
|
+
self._temperature = temperature
|
|
39
|
+
self.interaction_title = interaction_title
|
|
40
|
+
self.account = account
|
|
41
|
+
self.client = client
|
|
42
|
+
self.account_attendees = account_attendees
|
|
43
|
+
self.client_attendees = client_attendees
|
|
44
|
+
|
|
45
|
+
def split(self, content: str | list[tuple[int, str]]) -> TopicSplitResult:
|
|
46
|
+
"""Detect topic shifts in *content*.
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
content:
|
|
51
|
+
Either a plain string (paragraphs separated by blank lines)
|
|
52
|
+
or a list of ``(index, text)`` tuples.
|
|
53
|
+
|
|
54
|
+
Returns
|
|
55
|
+
-------
|
|
56
|
+
TopicSplitResult
|
|
57
|
+
Detected topic shifts with indexes and reasons.
|
|
58
|
+
"""
|
|
59
|
+
tuples = self._normalize_input(content)
|
|
60
|
+
if len(tuples) <= 1:
|
|
61
|
+
return TopicSplitResult()
|
|
62
|
+
return self._call_llm(tuples)
|
|
63
|
+
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
# Internal helpers
|
|
66
|
+
# ------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _normalize_input(
|
|
70
|
+
content: str | list[tuple[int, str]],
|
|
71
|
+
) -> list[tuple[int, str]]:
|
|
72
|
+
if isinstance(content, list):
|
|
73
|
+
return content
|
|
74
|
+
paragraphs = re.split(r"\n\n+", content.strip())
|
|
75
|
+
paragraphs = [p.strip() for p in paragraphs if p.strip()]
|
|
76
|
+
return [(i + 1, p) for i, p in enumerate(paragraphs)]
|
|
77
|
+
|
|
78
|
+
def _call_llm(self, tuples: list[tuple[int, str]]) -> TopicSplitResult:
|
|
79
|
+
llm = get_llm(
|
|
80
|
+
provider=self._provider,
|
|
81
|
+
model=self._model,
|
|
82
|
+
api_key=self._api_key,
|
|
83
|
+
temperature=self._temperature,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
prompt = ChatPromptTemplate.from_template(TRANSCRIPT_SPLITTER_PROMPT)
|
|
87
|
+
structured_llm = llm.with_structured_output(TopicSplitResult)
|
|
88
|
+
chain = prompt | structured_llm
|
|
89
|
+
|
|
90
|
+
transcript_str = "\n".join(f'({idx}, "{text}")' for idx, text in tuples)
|
|
91
|
+
max_idx = max(idx for idx, _ in tuples)
|
|
92
|
+
|
|
93
|
+
result: TopicSplitResult = chain.invoke(
|
|
94
|
+
{
|
|
95
|
+
"examples": TRANSCRIPT_SPLITTER_EXAMPLES,
|
|
96
|
+
"transcript": transcript_str,
|
|
97
|
+
"max_idx": max_idx,
|
|
98
|
+
"interaction_title": self.interaction_title,
|
|
99
|
+
"account": self.account,
|
|
100
|
+
"client": self.client,
|
|
101
|
+
"account_attendees": self.account_attendees,
|
|
102
|
+
"client_attendees": self.client_attendees,
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
return result
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Default implementations of the pipeline stage ABCs."""
|
|
2
|
+
|
|
3
|
+
from pulse_engine.processor.defaults.core_processor import DefaultCoreProcessor
|
|
4
|
+
from pulse_engine.processor.defaults.postprocessor import DefaultPostprocessor
|
|
5
|
+
from pulse_engine.processor.defaults.preprocessor import DefaultPreprocessor
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"DefaultPreprocessor",
|
|
9
|
+
"DefaultCoreProcessor",
|
|
10
|
+
"DefaultPostprocessor",
|
|
11
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Default core processor — wraps the existing run_core_processing function."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pulse_engine.processor.base import BaseCoreProcessor
|
|
6
|
+
from pulse_engine.processor.core.analysis import run_core_processing
|
|
7
|
+
from pulse_engine.processor.schemas import ProcessingContext
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DefaultCoreProcessor(BaseCoreProcessor):
|
|
11
|
+
def process(self, ctx: ProcessingContext) -> ProcessingContext:
|
|
12
|
+
return run_core_processing(ctx)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Default postprocessor — wraps the existing run_postprocessing function."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pulse_engine.processor.base import BasePostprocessor
|
|
6
|
+
from pulse_engine.processor.postprocessor.tasks import run_postprocessing
|
|
7
|
+
from pulse_engine.processor.schemas import ProcessingContext
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DefaultPostprocessor(BasePostprocessor):
|
|
11
|
+
def process(self, ctx: ProcessingContext) -> ProcessingContext:
|
|
12
|
+
return run_postprocessing(ctx)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Default preprocessor — wraps the existing run_preprocessing function."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pulse_engine.processor.base import BasePreprocessor
|
|
6
|
+
from pulse_engine.processor.preprocessor.tasks import run_preprocessing
|
|
7
|
+
from pulse_engine.processor.schemas import ProcessingContext
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DefaultPreprocessor(BasePreprocessor):
|
|
11
|
+
def process(self, ctx: ProcessingContext) -> ProcessingContext:
|
|
12
|
+
return run_preprocessing(ctx)
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pulse_engine.config import get_settings
|
|
6
|
+
|
|
7
|
+
_llm_instance: Any = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_llm(
|
|
11
|
+
provider: str | None = None,
|
|
12
|
+
model: str | None = None,
|
|
13
|
+
api_key: str | None = None,
|
|
14
|
+
temperature: float | None = None,
|
|
15
|
+
) -> Any:
|
|
16
|
+
"""Return a LangChain chat model, using Settings as defaults.
|
|
17
|
+
|
|
18
|
+
Constructor params override env-var defaults. The instance is cached
|
|
19
|
+
as a module singleton on first call.
|
|
20
|
+
"""
|
|
21
|
+
global _llm_instance
|
|
22
|
+
if _llm_instance is not None:
|
|
23
|
+
return _llm_instance
|
|
24
|
+
|
|
25
|
+
settings = get_settings()
|
|
26
|
+
provider = provider or settings.pulse_llm_provider
|
|
27
|
+
model = model or settings.pulse_llm_model
|
|
28
|
+
api_key = api_key or settings.pulse_llm_api_key
|
|
29
|
+
if temperature is None:
|
|
30
|
+
temperature = settings.pulse_llm_temperature
|
|
31
|
+
|
|
32
|
+
if provider == "openai":
|
|
33
|
+
from langchain_openai import ChatOpenAI
|
|
34
|
+
|
|
35
|
+
_llm_instance = ChatOpenAI(
|
|
36
|
+
model=model,
|
|
37
|
+
api_key=api_key, # type: ignore[arg-type]
|
|
38
|
+
temperature=temperature,
|
|
39
|
+
)
|
|
40
|
+
elif provider == "anthropic":
|
|
41
|
+
from langchain_anthropic import ChatAnthropic
|
|
42
|
+
|
|
43
|
+
_llm_instance = ChatAnthropic( # type: ignore[call-arg]
|
|
44
|
+
model=model,
|
|
45
|
+
api_key=api_key, # type: ignore[arg-type]
|
|
46
|
+
temperature=temperature,
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
msg = f"Unsupported LLM provider: {provider!r}. Use 'openai' or 'anthropic'."
|
|
50
|
+
raise ValueError(msg)
|
|
51
|
+
|
|
52
|
+
return _llm_instance
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def reset_llm() -> None:
|
|
56
|
+
"""Reset the cached LLM instance (useful for testing)."""
|
|
57
|
+
global _llm_instance
|
|
58
|
+
_llm_instance = None
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Gemini OCR provider using Google Generative AI."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from google import genai
|
|
7
|
+
from google.genai import types
|
|
8
|
+
|
|
9
|
+
from pulse_engine.processor.base import BaseOCRProvider
|
|
10
|
+
from pulse_engine.processor.schemas import OCRInput
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GeminiOCRProvider(BaseOCRProvider):
|
|
14
|
+
"""OCR provider powered by Google Gemini's vision capabilities."""
|
|
15
|
+
|
|
16
|
+
def _get_client(self, api_key: str) -> Any:
|
|
17
|
+
return genai.Client(api_key=api_key)
|
|
18
|
+
|
|
19
|
+
def _read_file_bytes(self, ocr_input: OCRInput) -> bytes:
|
|
20
|
+
if ocr_input.file_bytes:
|
|
21
|
+
return ocr_input.file_bytes
|
|
22
|
+
if ocr_input.file_path:
|
|
23
|
+
return Path(ocr_input.file_path).read_bytes()
|
|
24
|
+
msg = "OCRInput must provide either file_bytes or file_path."
|
|
25
|
+
raise ValueError(msg)
|
|
26
|
+
|
|
27
|
+
def extract(self, ocr_input: OCRInput) -> Any:
|
|
28
|
+
file_bytes = self._read_file_bytes(ocr_input)
|
|
29
|
+
client = self._get_client(ocr_input.api_key)
|
|
30
|
+
|
|
31
|
+
response = client.models.generate_content(
|
|
32
|
+
model=ocr_input.model,
|
|
33
|
+
contents=[
|
|
34
|
+
types.Content(
|
|
35
|
+
role="user",
|
|
36
|
+
parts=[
|
|
37
|
+
types.Part.from_bytes(
|
|
38
|
+
data=file_bytes,
|
|
39
|
+
mime_type=ocr_input.mime_type,
|
|
40
|
+
),
|
|
41
|
+
types.Part.from_text(text=ocr_input.prompt),
|
|
42
|
+
],
|
|
43
|
+
),
|
|
44
|
+
],
|
|
45
|
+
config=types.GenerateContentConfig(
|
|
46
|
+
temperature=ocr_input.temperature,
|
|
47
|
+
max_output_tokens=ocr_input.max_output_tokens,
|
|
48
|
+
response_mime_type="application/json",
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return response
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Processing pipeline — orchestrates pre-processing, core, and post-processing.
|
|
2
|
+
|
|
3
|
+
Stages are pluggable via the base ABCs. Pass ``None`` to skip a stage,
|
|
4
|
+
or omit (use the sentinel default) to get the engine's built-in implementation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
from pulse_engine.processor.base import (
|
|
14
|
+
BaseCoreProcessor,
|
|
15
|
+
BasePostprocessor,
|
|
16
|
+
BasePreprocessor,
|
|
17
|
+
)
|
|
18
|
+
from pulse_engine.processor.schemas import ProcessingContext, ProcessingError
|
|
19
|
+
from pulse_engine.storage.knowledge_base import KnowledgeBaseService
|
|
20
|
+
|
|
21
|
+
logger = structlog.get_logger()
|
|
22
|
+
|
|
23
|
+
_SENTINEL: Any = object()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ProcessingPipeline:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
kb_service: KnowledgeBaseService | None = None,
|
|
30
|
+
preprocessor: BasePreprocessor | None = _SENTINEL,
|
|
31
|
+
core_processor: BaseCoreProcessor | None = _SENTINEL,
|
|
32
|
+
postprocessor: BasePostprocessor | None = _SENTINEL,
|
|
33
|
+
) -> None:
|
|
34
|
+
from pulse_engine.processor.defaults import (
|
|
35
|
+
DefaultCoreProcessor,
|
|
36
|
+
DefaultPostprocessor,
|
|
37
|
+
DefaultPreprocessor,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
self._kb_service = kb_service
|
|
41
|
+
self._preprocessor = (
|
|
42
|
+
DefaultPreprocessor() if preprocessor is _SENTINEL else preprocessor
|
|
43
|
+
)
|
|
44
|
+
self._core_processor = (
|
|
45
|
+
DefaultCoreProcessor() if core_processor is _SENTINEL else core_processor
|
|
46
|
+
)
|
|
47
|
+
self._postprocessor = (
|
|
48
|
+
DefaultPostprocessor() if postprocessor is _SENTINEL else postprocessor
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
async def run(self, ctx: ProcessingContext) -> ProcessingContext:
|
|
52
|
+
"""Run the full 3-stage pipeline."""
|
|
53
|
+
ctx = await self.run_preprocess(ctx)
|
|
54
|
+
ctx = await self.run_core(ctx)
|
|
55
|
+
ctx = await self.run_postprocess(ctx)
|
|
56
|
+
|
|
57
|
+
if ctx.options.store_results and self._kb_service and ctx.documents:
|
|
58
|
+
try:
|
|
59
|
+
await self._kb_service.store_documents(ctx.tenant_id, ctx.documents)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
logger.warning("storage_failed", error=str(e))
|
|
62
|
+
ctx.errors.append(
|
|
63
|
+
ProcessingError(
|
|
64
|
+
stage="storage", task="store_documents", message=str(e)
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return ctx
|
|
69
|
+
|
|
70
|
+
async def run_preprocess(self, ctx: ProcessingContext) -> ProcessingContext:
|
|
71
|
+
if self._preprocessor is None:
|
|
72
|
+
return ctx
|
|
73
|
+
try:
|
|
74
|
+
ctx = self._preprocessor.process(ctx)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
ctx.errors.append(
|
|
77
|
+
ProcessingError(
|
|
78
|
+
stage="preprocessing", task="run_preprocess", message=str(e)
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
return ctx
|
|
82
|
+
|
|
83
|
+
async def run_core(self, ctx: ProcessingContext) -> ProcessingContext:
|
|
84
|
+
if self._core_processor is None:
|
|
85
|
+
return ctx
|
|
86
|
+
try:
|
|
87
|
+
ctx = self._core_processor.process(ctx)
|
|
88
|
+
except Exception as e:
|
|
89
|
+
ctx.errors.append(
|
|
90
|
+
ProcessingError(
|
|
91
|
+
stage="core_processing", task="run_core", message=str(e)
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
return ctx
|
|
95
|
+
|
|
96
|
+
async def run_postprocess(self, ctx: ProcessingContext) -> ProcessingContext:
|
|
97
|
+
if self._postprocessor is None:
|
|
98
|
+
return ctx
|
|
99
|
+
try:
|
|
100
|
+
ctx = self._postprocessor.process(ctx)
|
|
101
|
+
except Exception as e:
|
|
102
|
+
ctx.errors.append(
|
|
103
|
+
ProcessingError(
|
|
104
|
+
stage="postprocessing", task="run_postprocess", message=str(e)
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
return ctx
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pulse_engine.config import get_settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def generate_embedding(
|
|
9
|
+
text: str,
|
|
10
|
+
model_name: str = "text-embedding-3-small",
|
|
11
|
+
provider: str | None = None,
|
|
12
|
+
) -> list[float]:
|
|
13
|
+
"""Generate embedding for *text* using OpenAI.
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
text:
|
|
18
|
+
The text to embed.
|
|
19
|
+
model_name:
|
|
20
|
+
Ignored (kept for backward compatibility). Model is read from settings.
|
|
21
|
+
provider:
|
|
22
|
+
Ignored (kept for backward compatibility). Always uses OpenAI.
|
|
23
|
+
"""
|
|
24
|
+
settings = get_settings()
|
|
25
|
+
return _openai_embedding(text, settings)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _openai_embedding(text: str, settings: Any) -> list[float]:
|
|
29
|
+
from langchain_openai import OpenAIEmbeddings
|
|
30
|
+
|
|
31
|
+
api_key = settings.pulse_openai_api_key or settings.pulse_llm_api_key
|
|
32
|
+
model = settings.pulse_openai_embedding_model
|
|
33
|
+
embedder = OpenAIEmbeddings(model=model, api_key=api_key)
|
|
34
|
+
return list(embedder.embed_query(text))
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pulse_engine.processor.postprocessor.embeddings import generate_embedding
|
|
7
|
+
from pulse_engine.processor.schemas import (
|
|
8
|
+
ContentChunk,
|
|
9
|
+
ProcessingContext,
|
|
10
|
+
ProcessingError,
|
|
11
|
+
)
|
|
12
|
+
from pulse_engine.storage.schemas import Document, DocumentSource
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def format_for_storage(ctx: ProcessingContext) -> list[Document]:
|
|
16
|
+
"""Convert processing context chunks into Document objects for storage."""
|
|
17
|
+
# Map source types to DocumentSource enum
|
|
18
|
+
source_map: dict[str, DocumentSource] = {
|
|
19
|
+
"speech": DocumentSource.speech,
|
|
20
|
+
"tweet": DocumentSource.tweet,
|
|
21
|
+
"youtube": DocumentSource.youtube,
|
|
22
|
+
"processed": DocumentSource.processed,
|
|
23
|
+
}
|
|
24
|
+
doc_source = source_map.get(ctx.source_type, DocumentSource.processed)
|
|
25
|
+
|
|
26
|
+
documents: list[Document] = []
|
|
27
|
+
for i, chunk in enumerate(ctx.chunks):
|
|
28
|
+
metadata: dict[str, Any] = {
|
|
29
|
+
"source_type": ctx.source_type,
|
|
30
|
+
"product": ctx.product,
|
|
31
|
+
"tenant_id": ctx.tenant_id,
|
|
32
|
+
"language": ctx.language,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
metadata_chunk: dict[str, Any] = {
|
|
36
|
+
"chunk_index": chunk.chunk_index,
|
|
37
|
+
"token_count": chunk.token_count,
|
|
38
|
+
"sentiment": chunk.sentiment,
|
|
39
|
+
"topics": chunk.topics,
|
|
40
|
+
"entities": [e.model_dump() for e in chunk.entities],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Get embeddings if available
|
|
44
|
+
content_embedding = ctx.embeddings[i] if i < len(ctx.embeddings) else None
|
|
45
|
+
|
|
46
|
+
doc = Document(
|
|
47
|
+
doc_id=ctx.source_id,
|
|
48
|
+
source=doc_source,
|
|
49
|
+
chunk_id=str(chunk.chunk_index),
|
|
50
|
+
content=chunk.content,
|
|
51
|
+
metadata=metadata,
|
|
52
|
+
metadata_chunk=metadata_chunk,
|
|
53
|
+
content_summary=ctx.summary,
|
|
54
|
+
content_title=None,
|
|
55
|
+
embedding=content_embedding,
|
|
56
|
+
content_embeddings=content_embedding,
|
|
57
|
+
content_summary_embeddings=None,
|
|
58
|
+
content_title_embeddings=None,
|
|
59
|
+
)
|
|
60
|
+
documents.append(doc)
|
|
61
|
+
|
|
62
|
+
return documents
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def compute_quality_score(chunk: ContentChunk) -> float:
|
|
66
|
+
"""Compute a heuristic quality score for a chunk (0.0 - 1.0)."""
|
|
67
|
+
score = 0.0
|
|
68
|
+
|
|
69
|
+
# Content length factor (0 - 0.4)
|
|
70
|
+
length = len(chunk.content)
|
|
71
|
+
if length >= 100:
|
|
72
|
+
score += 0.4
|
|
73
|
+
elif length >= 50:
|
|
74
|
+
score += 0.3
|
|
75
|
+
elif length >= 20:
|
|
76
|
+
score += 0.2
|
|
77
|
+
else:
|
|
78
|
+
score += 0.1
|
|
79
|
+
|
|
80
|
+
# Entity count factor (0 - 0.2)
|
|
81
|
+
entity_count = len(chunk.entities)
|
|
82
|
+
score += min(entity_count * 0.05, 0.2)
|
|
83
|
+
|
|
84
|
+
# Topic count factor (0 - 0.2)
|
|
85
|
+
topic_count = len(chunk.topics)
|
|
86
|
+
score += min(topic_count * 0.04, 0.2)
|
|
87
|
+
|
|
88
|
+
# Token count factor (0 - 0.2) — prefer chunks with reasonable token counts
|
|
89
|
+
if 50 <= chunk.token_count <= 512:
|
|
90
|
+
score += 0.2
|
|
91
|
+
elif chunk.token_count > 0:
|
|
92
|
+
score += 0.1
|
|
93
|
+
|
|
94
|
+
return min(score, 1.0)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _cosine_similarity(a: list[float], b: list[float]) -> float:
|
|
98
|
+
"""Compute cosine similarity between two vectors."""
|
|
99
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
100
|
+
norm_a = math.sqrt(sum(x * x for x in a))
|
|
101
|
+
norm_b = math.sqrt(sum(x * x for x in b))
|
|
102
|
+
if norm_a == 0 or norm_b == 0:
|
|
103
|
+
return 0.0
|
|
104
|
+
return dot / (norm_a * norm_b)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def deduplicate(embeddings: list[list[float]], threshold: float = 0.95) -> list[bool]:
|
|
108
|
+
"""
|
|
109
|
+
Check for duplicate embeddings using cosine similarity.
|
|
110
|
+
Returns list of bools: True = keep, False = duplicate.
|
|
111
|
+
"""
|
|
112
|
+
if not embeddings:
|
|
113
|
+
return []
|
|
114
|
+
|
|
115
|
+
n = len(embeddings)
|
|
116
|
+
keep = [True] * n
|
|
117
|
+
|
|
118
|
+
if n <= 1:
|
|
119
|
+
return keep
|
|
120
|
+
|
|
121
|
+
for i in range(n):
|
|
122
|
+
if not keep[i]:
|
|
123
|
+
continue
|
|
124
|
+
for j in range(i + 1, n):
|
|
125
|
+
if not keep[j]:
|
|
126
|
+
continue
|
|
127
|
+
if _cosine_similarity(embeddings[i], embeddings[j]) >= threshold:
|
|
128
|
+
keep[j] = False
|
|
129
|
+
|
|
130
|
+
return keep
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def run_postprocessing(ctx: ProcessingContext) -> ProcessingContext:
|
|
134
|
+
"""Run all postprocessing tasks on the context."""
|
|
135
|
+
try:
|
|
136
|
+
# Generate embeddings
|
|
137
|
+
if ctx.options.enable_embedding:
|
|
138
|
+
for chunk in ctx.chunks:
|
|
139
|
+
emb = generate_embedding(
|
|
140
|
+
chunk.content,
|
|
141
|
+
ctx.options.embedding_model,
|
|
142
|
+
provider=ctx.options.embedding_provider,
|
|
143
|
+
)
|
|
144
|
+
ctx.embeddings.append(emb)
|
|
145
|
+
|
|
146
|
+
# Compute quality scores
|
|
147
|
+
for chunk in ctx.chunks:
|
|
148
|
+
score = compute_quality_score(chunk)
|
|
149
|
+
ctx.quality_scores.append(score)
|
|
150
|
+
|
|
151
|
+
# Deduplication
|
|
152
|
+
if ctx.options.enable_dedup and ctx.embeddings:
|
|
153
|
+
keep_flags = deduplicate(ctx.embeddings)
|
|
154
|
+
# Filter out duplicates
|
|
155
|
+
new_chunks = []
|
|
156
|
+
new_embeddings = []
|
|
157
|
+
new_scores = []
|
|
158
|
+
for i, keep in enumerate(keep_flags):
|
|
159
|
+
if keep:
|
|
160
|
+
new_chunks.append(ctx.chunks[i])
|
|
161
|
+
if i < len(ctx.embeddings):
|
|
162
|
+
new_embeddings.append(ctx.embeddings[i])
|
|
163
|
+
if i < len(ctx.quality_scores):
|
|
164
|
+
new_scores.append(ctx.quality_scores[i])
|
|
165
|
+
ctx.chunks = new_chunks
|
|
166
|
+
ctx.embeddings = new_embeddings
|
|
167
|
+
ctx.quality_scores = new_scores
|
|
168
|
+
|
|
169
|
+
# Format for storage
|
|
170
|
+
ctx.documents = format_for_storage(ctx)
|
|
171
|
+
|
|
172
|
+
ctx.stages_completed.append("postprocessing")
|
|
173
|
+
except Exception as e:
|
|
174
|
+
ctx.errors.append(
|
|
175
|
+
ProcessingError(
|
|
176
|
+
stage="postprocessing", task="run_postprocessing", message=str(e)
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return ctx
|
|
File without changes
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import unicodedata
|
|
5
|
+
|
|
6
|
+
from bs4 import BeautifulSoup
|
|
7
|
+
from langdetect import detect
|
|
8
|
+
from langdetect.lang_detect_exception import LangDetectException
|
|
9
|
+
|
|
10
|
+
from pulse_engine.processor.schemas import ProcessingContext, ProcessingError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def clean_html(text: str) -> str:
|
|
14
|
+
"""Strip HTML tags, decode entities, and normalize whitespace."""
|
|
15
|
+
soup = BeautifulSoup(text, "html.parser")
|
|
16
|
+
cleaned = str(soup.get_text(separator=" "))
|
|
17
|
+
# Normalize whitespace
|
|
18
|
+
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
|
19
|
+
return cleaned
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def normalize_text(text: str) -> str:
|
|
23
|
+
"""Apply Unicode NFC normalization and strip control characters."""
|
|
24
|
+
text = unicodedata.normalize("NFC", text)
|
|
25
|
+
# Remove control characters except newlines and tabs
|
|
26
|
+
text = "".join(
|
|
27
|
+
ch
|
|
28
|
+
for ch in text
|
|
29
|
+
if not unicodedata.category(ch).startswith("C") or ch in "\n\t"
|
|
30
|
+
)
|
|
31
|
+
return text
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def detect_language(text: str) -> str:
|
|
35
|
+
"""Detect language using langdetect, return ISO 639-1 code."""
|
|
36
|
+
try:
|
|
37
|
+
return str(detect(text))
|
|
38
|
+
except LangDetectException:
|
|
39
|
+
return "unknown"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def validate_content(text: str, min_length: int = 10) -> bool:
|
|
43
|
+
"""Reject empty or too-short content."""
|
|
44
|
+
if not text or not text.strip():
|
|
45
|
+
return False
|
|
46
|
+
return len(text.strip()) >= min_length
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run_preprocessing(ctx: ProcessingContext) -> ProcessingContext:
|
|
50
|
+
"""Run all preprocessing tasks in sequence on the context."""
|
|
51
|
+
try:
|
|
52
|
+
cleaned = clean_html(ctx.raw_content)
|
|
53
|
+
cleaned = normalize_text(cleaned)
|
|
54
|
+
ctx.cleaned_content = cleaned
|
|
55
|
+
|
|
56
|
+
if validate_content(cleaned):
|
|
57
|
+
ctx.language = detect_language(cleaned)
|
|
58
|
+
else:
|
|
59
|
+
ctx.language = "unknown"
|
|
60
|
+
|
|
61
|
+
ctx.stages_completed.append("preprocessing")
|
|
62
|
+
except Exception as e:
|
|
63
|
+
ctx.errors.append(
|
|
64
|
+
ProcessingError(
|
|
65
|
+
stage="preprocessing",
|
|
66
|
+
task="run_preprocessing",
|
|
67
|
+
message=str(e),
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return ctx
|