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.
Files changed (150) hide show
  1. pulse_engine/__init__.py +0 -0
  2. pulse_engine/adapters/__init__.py +58 -0
  3. pulse_engine/adapters/audio_transcription.py +167 -0
  4. pulse_engine/adapters/batcher.py +36 -0
  5. pulse_engine/adapters/digital_news.py +128 -0
  6. pulse_engine/adapters/digital_news_metadata.py +536 -0
  7. pulse_engine/adapters/exceptions.py +10 -0
  8. pulse_engine/adapters/models.py +134 -0
  9. pulse_engine/adapters/opensearch_storage.py +160 -0
  10. pulse_engine/adapters/speech_content.py +130 -0
  11. pulse_engine/adapters/speech_metadata.py +374 -0
  12. pulse_engine/adapters/twitter.py +423 -0
  13. pulse_engine/adapters/youtube_downloader.py +186 -0
  14. pulse_engine/adapters/youtube_metadata.py +261 -0
  15. pulse_engine/api/__init__.py +0 -0
  16. pulse_engine/api/v1/__init__.py +0 -0
  17. pulse_engine/api/v1/auth.py +91 -0
  18. pulse_engine/api/v1/health.py +62 -0
  19. pulse_engine/api/v1/router.py +16 -0
  20. pulse_engine/chain_recovery.py +131 -0
  21. pulse_engine/cli/__init__.py +0 -0
  22. pulse_engine/cli/main.py +169 -0
  23. pulse_engine/cli/templates/cookiecutter.json +4 -0
  24. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/.gitignore +13 -0
  25. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/Dockerfile +32 -0
  26. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/pipeline.yaml +17 -0
  27. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/pyproject.toml +25 -0
  28. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/src/pulse_{{cookiecutter.product_slug}}/__init__.py +8 -0
  29. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/__init__.py +0 -0
  30. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/unit/__init__.py +0 -0
  31. pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/unit/test_manifest.py +15 -0
  32. pulse_engine/client.py +95 -0
  33. pulse_engine/config.py +157 -0
  34. pulse_engine/core/__init__.py +0 -0
  35. pulse_engine/core/error_handlers.py +64 -0
  36. pulse_engine/core/exceptions.py +67 -0
  37. pulse_engine/core/job_token.py +109 -0
  38. pulse_engine/core/logging.py +45 -0
  39. pulse_engine/core/scope.py +23 -0
  40. pulse_engine/core/security.py +130 -0
  41. pulse_engine/database.py +30 -0
  42. pulse_engine/dependencies.py +166 -0
  43. pulse_engine/deployment/__init__.py +0 -0
  44. pulse_engine/deployment/backend_deployment_repository.py +83 -0
  45. pulse_engine/deployment/backends/__init__.py +0 -0
  46. pulse_engine/deployment/backends/base.py +50 -0
  47. pulse_engine/deployment/backends/exceptions.py +20 -0
  48. pulse_engine/deployment/backends/native_lambda.py +125 -0
  49. pulse_engine/deployment/backends/prefect_ecs.py +116 -0
  50. pulse_engine/deployment/backends/prefect_k8s.py +131 -0
  51. pulse_engine/deployment/backends/registry.py +50 -0
  52. pulse_engine/deployment/infra_provisioner.py +285 -0
  53. pulse_engine/deployment/job_launcher.py +178 -0
  54. pulse_engine/deployment/models.py +48 -0
  55. pulse_engine/deployment/repository.py +54 -0
  56. pulse_engine/deployment/router.py +22 -0
  57. pulse_engine/deployment/schemas.py +18 -0
  58. pulse_engine/deployment/service.py +65 -0
  59. pulse_engine/extractor/__init__.py +0 -0
  60. pulse_engine/extractor/adapters/__init__.py +0 -0
  61. pulse_engine/extractor/base.py +48 -0
  62. pulse_engine/extractor/models.py +50 -0
  63. pulse_engine/extractor/orchestrator/__init__.py +15 -0
  64. pulse_engine/extractor/orchestrator/base.py +34 -0
  65. pulse_engine/extractor/orchestrator/noop.py +37 -0
  66. pulse_engine/extractor/orchestrator/prefect.py +163 -0
  67. pulse_engine/extractor/repository.py +163 -0
  68. pulse_engine/extractor/router.py +102 -0
  69. pulse_engine/extractor/schemas.py +93 -0
  70. pulse_engine/extractor/service.py +431 -0
  71. pulse_engine/extractor/stage_models.py +36 -0
  72. pulse_engine/extractor/stage_repository.py +109 -0
  73. pulse_engine/main.py +195 -0
  74. pulse_engine/mcp/__init__.py +0 -0
  75. pulse_engine/mcp/__main__.py +5 -0
  76. pulse_engine/mcp/server.py +108 -0
  77. pulse_engine/mcp/tools_jobs.py +159 -0
  78. pulse_engine/mcp/tools_kb.py +88 -0
  79. pulse_engine/mcp/tools_modules.py +115 -0
  80. pulse_engine/mcp/tools_pipelines.py +215 -0
  81. pulse_engine/mcp/tools_processor.py +208 -0
  82. pulse_engine/middleware/__init__.py +0 -0
  83. pulse_engine/middleware/rate_limit.py +144 -0
  84. pulse_engine/middleware/request_id.py +16 -0
  85. pulse_engine/middleware/security_headers.py +25 -0
  86. pulse_engine/middleware/tenant.py +90 -0
  87. pulse_engine/pipeline/__init__.py +0 -0
  88. pulse_engine/pipeline/config_parser.py +148 -0
  89. pulse_engine/pipeline/expression.py +268 -0
  90. pulse_engine/pipeline/models.py +98 -0
  91. pulse_engine/pipeline/repositories.py +224 -0
  92. pulse_engine/pipeline/router_modules.py +66 -0
  93. pulse_engine/pipeline/router_pipelines.py +198 -0
  94. pulse_engine/pipeline/schemas.py +200 -0
  95. pulse_engine/pipeline/service.py +250 -0
  96. pulse_engine/pipeline/translators/__init__.py +44 -0
  97. pulse_engine/pipeline/translators/airflow_status.py +11 -0
  98. pulse_engine/pipeline/translators/airflow_translator.py +22 -0
  99. pulse_engine/pipeline/translators/base.py +42 -0
  100. pulse_engine/pipeline/translators/prefect_status.py +93 -0
  101. pulse_engine/pipeline/translators/prefect_translator.py +195 -0
  102. pulse_engine/processor/__init__.py +0 -0
  103. pulse_engine/processor/base.py +36 -0
  104. pulse_engine/processor/core/__init__.py +0 -0
  105. pulse_engine/processor/core/analysis.py +148 -0
  106. pulse_engine/processor/core/chunking.py +158 -0
  107. pulse_engine/processor/core/prompts.py +340 -0
  108. pulse_engine/processor/core/topic_splitter.py +105 -0
  109. pulse_engine/processor/defaults/__init__.py +11 -0
  110. pulse_engine/processor/defaults/core_processor.py +12 -0
  111. pulse_engine/processor/defaults/postprocessor.py +12 -0
  112. pulse_engine/processor/defaults/preprocessor.py +12 -0
  113. pulse_engine/processor/llm/__init__.py +0 -0
  114. pulse_engine/processor/llm/provider.py +58 -0
  115. pulse_engine/processor/ocr/gemini.py +52 -0
  116. pulse_engine/processor/pipeline.py +107 -0
  117. pulse_engine/processor/postprocessor/__init__.py +0 -0
  118. pulse_engine/processor/postprocessor/embeddings.py +34 -0
  119. pulse_engine/processor/postprocessor/tasks.py +180 -0
  120. pulse_engine/processor/preprocessor/__init__.py +0 -0
  121. pulse_engine/processor/preprocessor/tasks.py +71 -0
  122. pulse_engine/processor/router.py +192 -0
  123. pulse_engine/processor/schemas.py +167 -0
  124. pulse_engine/registry.py +117 -0
  125. pulse_engine/runners/__init__.py +0 -0
  126. pulse_engine/runners/lambda_runner.py +26 -0
  127. pulse_engine/runners/pipeline_runner.py +43 -0
  128. pulse_engine/runners/prefect_pipeline_flow.py +904 -0
  129. pulse_engine/runners/prefect_runner.py +33 -0
  130. pulse_engine/s3.py +72 -0
  131. pulse_engine/secrets.py +46 -0
  132. pulse_engine/services/__init__.py +0 -0
  133. pulse_engine/services/bootstrap.py +211 -0
  134. pulse_engine/services/opensearch.py +84 -0
  135. pulse_engine/storage/__init__.py +0 -0
  136. pulse_engine/storage/connectors/__init__.py +0 -0
  137. pulse_engine/storage/connectors/athena.py +226 -0
  138. pulse_engine/storage/connectors/base.py +32 -0
  139. pulse_engine/storage/connectors/opensearch.py +344 -0
  140. pulse_engine/storage/knowledge_base.py +68 -0
  141. pulse_engine/storage/router.py +78 -0
  142. pulse_engine/storage/schemas.py +93 -0
  143. pulse_engine/testing/__init__.py +13 -0
  144. pulse_engine/testing/fixtures.py +50 -0
  145. pulse_engine/testing/mocks.py +104 -0
  146. pulse_engine/worker.py +53 -0
  147. pulse_engine-0.2.0.dist-info/METADATA +654 -0
  148. pulse_engine-0.2.0.dist-info/RECORD +150 -0
  149. pulse_engine-0.2.0.dist-info/WHEEL +4 -0
  150. 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