test
This commit is contained in:
@@ -82,6 +82,30 @@ class KnowledgeRepository:
|
||||
raise KeyError(f"Source not found: {source_name}")
|
||||
return source
|
||||
|
||||
def reset_project_runtime_data(self, project_id: int) -> dict[str, int]:
|
||||
delete_order = [
|
||||
models.FeedbackLog,
|
||||
models.UserPreference,
|
||||
models.UserProfile,
|
||||
models.CrawlJob,
|
||||
models.ExtractionLog,
|
||||
models.Evidence,
|
||||
models.Relation,
|
||||
models.Claim,
|
||||
models.Attribute,
|
||||
models.Page,
|
||||
models.Entity,
|
||||
]
|
||||
deleted: dict[str, int] = {}
|
||||
for table_model in delete_order:
|
||||
count = (
|
||||
self.session.query(table_model)
|
||||
.filter(table_model.project_id == project_id)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
deleted[table_model.__tablename__] = int(count or 0)
|
||||
return deleted
|
||||
|
||||
def upsert_page(
|
||||
self,
|
||||
project_id: int,
|
||||
|
||||
@@ -29,6 +29,10 @@ class LLMJsonExtractor(AIExtractor):
|
||||
base_url: str | None = None,
|
||||
timeout_seconds: int = 300,
|
||||
):
|
||||
# Local LM Studio runs on consumer hardware; keep a shorter timeout so
|
||||
# we can quickly fallback instead of stalling a crawl worker for 5+ min.
|
||||
if provider == "lm_studio" and timeout_seconds == 300:
|
||||
timeout_seconds = 120
|
||||
self.domain = domain
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
@@ -36,25 +40,18 @@ class LLMJsonExtractor(AIExtractor):
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
def extract(self, page_text: str, project_config: ProjectConfig) -> ExtractionBundle:
|
||||
try:
|
||||
raw = self.complete_json(page_text, project_config)
|
||||
except Exception as exc:
|
||||
return self._fallback_bundle(page_text, project_config, str(exc))
|
||||
bundle = ExtractionBundle(
|
||||
entities=parse_entities(raw.get("entities", [])),
|
||||
claims=parse_claims(raw.get("claims", [])),
|
||||
extractor_name=self.name,
|
||||
provider=self.provider,
|
||||
raw_output={
|
||||
"provider": self.provider,
|
||||
"model": self.model,
|
||||
"entity_count": len(raw.get("entities", [])),
|
||||
"claim_count": len(raw.get("claims", [])),
|
||||
},
|
||||
)
|
||||
if not bundle.entities or not bundle.claims:
|
||||
return self._fallback_bundle(page_text, project_config, "AI returned no usable entities or claims")
|
||||
return self.normalize_to_ontology(bundle, project_config.ontology)
|
||||
errors: list[str] = []
|
||||
for compact_mode in (False, True):
|
||||
mode_name = "compact_retry" if compact_mode else "primary"
|
||||
try:
|
||||
raw = self.complete_json(page_text, project_config, compact=compact_mode)
|
||||
bundle = self._bundle_from_raw(raw, mode_name)
|
||||
if bundle.entities and bundle.claims:
|
||||
return self.normalize_to_ontology(bundle, project_config.ontology)
|
||||
errors.append(f"{mode_name}: AI returned no usable entities or claims")
|
||||
except Exception as exc:
|
||||
errors.append(f"{mode_name}: {exc}")
|
||||
return self._fallback_bundle(page_text, project_config, " | ".join(errors))
|
||||
|
||||
def extract_entities(self, page_text: str, project_config: ProjectConfig) -> list[ExtractedEntity]:
|
||||
return self.extract(page_text, project_config).entities
|
||||
@@ -80,10 +77,22 @@ class LLMJsonExtractor(AIExtractor):
|
||||
claim.predicate = normalize_predicate(claim.predicate, ontology)
|
||||
return bundle
|
||||
|
||||
def complete_json(self, page_text: str, project_config: ProjectConfig) -> dict[str, Any]:
|
||||
prompt = build_extraction_prompt(page_text, project_config)
|
||||
def complete_json(self, page_text: str, project_config: ProjectConfig, compact: bool = False) -> dict[str, Any]:
|
||||
if self.provider == "lm_studio":
|
||||
char_limit = 1200 if compact else 2200
|
||||
max_tokens = 220 if compact else 420
|
||||
else:
|
||||
char_limit = 2200 if compact else 4000
|
||||
max_tokens = 400 if compact else 800
|
||||
prompt = build_extraction_prompt(page_text, project_config, char_limit=char_limit)
|
||||
if self.provider == "openai":
|
||||
return self._complete_openai_compatible(prompt, "OPENAI_API_KEY", "OPENAI_MODEL", self.base_url)
|
||||
return self._complete_openai_compatible(
|
||||
prompt,
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_MODEL",
|
||||
self.base_url,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
if self.provider == "lm_studio":
|
||||
return self._complete_openai_compatible(
|
||||
prompt,
|
||||
@@ -91,9 +100,10 @@ class LLMJsonExtractor(AIExtractor):
|
||||
"LM_STUDIO_MODEL",
|
||||
normalize_openai_chat_url(self.base_url or "http://localhost:1234/v1"),
|
||||
api_key_optional=True,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
if self.provider == "ollama":
|
||||
return self._complete_ollama(prompt)
|
||||
return self._complete_ollama(prompt, max_tokens=max_tokens)
|
||||
raise ValueError(f"Unsupported AI extractor provider: {self.provider}")
|
||||
|
||||
def _fallback_bundle(self, page_text: str, project_config: ProjectConfig, error: str) -> ExtractionBundle:
|
||||
@@ -104,13 +114,14 @@ class LLMJsonExtractor(AIExtractor):
|
||||
|
||||
bundle = GenericRuleBasedExtractor().extract(page_text, project_config)
|
||||
bundle.extractor_name = f"{self.name}_with_rule_fallback"
|
||||
bundle.provider = f"{self.provider}_fallback"
|
||||
bundle.provider = self.provider
|
||||
bundle.raw_output = {
|
||||
**bundle.raw_output,
|
||||
"ai_provider": self.provider,
|
||||
"ai_model": self.model,
|
||||
"ai_error": error,
|
||||
"ai_warning": error,
|
||||
"fallback": "rule_based",
|
||||
"extraction_mode": "fallback",
|
||||
}
|
||||
for entity in bundle.entities:
|
||||
entity.metadata["ai_fallback_reason"] = error
|
||||
@@ -119,6 +130,21 @@ class LLMJsonExtractor(AIExtractor):
|
||||
claim.confidence_reason = f"{claim.confidence_reason}; AI fallback: {error}" if claim.confidence_reason else error
|
||||
return bundle
|
||||
|
||||
def _bundle_from_raw(self, raw: dict[str, Any], mode: str) -> ExtractionBundle:
|
||||
return ExtractionBundle(
|
||||
entities=parse_entities(raw.get("entities", [])),
|
||||
claims=parse_claims(raw.get("claims", [])),
|
||||
extractor_name=self.name,
|
||||
provider=self.provider,
|
||||
raw_output={
|
||||
"provider": self.provider,
|
||||
"model": self.model,
|
||||
"entity_count": len(raw.get("entities", [])),
|
||||
"claim_count": len(raw.get("claims", [])),
|
||||
"extraction_mode": mode,
|
||||
},
|
||||
)
|
||||
|
||||
def _complete_openai_compatible(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -126,6 +152,7 @@ class LLMJsonExtractor(AIExtractor):
|
||||
model_env: str,
|
||||
endpoint: str | None,
|
||||
api_key_optional: bool = False,
|
||||
max_tokens: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
api_key = os.getenv(api_key_env)
|
||||
model = self.model or os.getenv(model_env)
|
||||
@@ -150,6 +177,7 @@ class LLMJsonExtractor(AIExtractor):
|
||||
],
|
||||
"temperature": 0,
|
||||
"response_format": extraction_response_format(),
|
||||
"max_tokens": max_tokens,
|
||||
},
|
||||
timeout=self.timeout_seconds,
|
||||
)
|
||||
@@ -159,7 +187,7 @@ class LLMJsonExtractor(AIExtractor):
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
return parse_json_content(content, retry=lambda bad: self._repair_json_with_model(bad, endpoint, headers, model))
|
||||
|
||||
def _complete_ollama(self, prompt: str) -> dict[str, Any]:
|
||||
def _complete_ollama(self, prompt: str, max_tokens: int | None = None) -> dict[str, Any]:
|
||||
model = self.model or os.getenv("OLLAMA_MODEL")
|
||||
if not model:
|
||||
raise RuntimeError("Ollama model is required. Set UI model field or OLLAMA_MODEL.")
|
||||
@@ -174,6 +202,7 @@ class LLMJsonExtractor(AIExtractor):
|
||||
],
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {"num_predict": max_tokens} if max_tokens else {},
|
||||
},
|
||||
timeout=self.timeout_seconds,
|
||||
)
|
||||
@@ -208,6 +237,7 @@ class LLMJsonExtractor(AIExtractor):
|
||||
},
|
||||
],
|
||||
"temperature": 0,
|
||||
"max_tokens": 300,
|
||||
},
|
||||
timeout=self.timeout_seconds,
|
||||
)
|
||||
@@ -216,8 +246,8 @@ class LLMJsonExtractor(AIExtractor):
|
||||
return parse_json_content(response.json()["choices"][0]["message"]["content"])
|
||||
|
||||
|
||||
def build_extraction_prompt(page_text: str, project_config: ProjectConfig) -> str:
|
||||
clipped_text = page_text[:6000]
|
||||
def build_extraction_prompt(page_text: str, project_config: ProjectConfig, char_limit: int = 4000) -> str:
|
||||
clipped_text = prepare_page_text_for_prompt(page_text, char_limit)
|
||||
ontology = project_config.ontology or {}
|
||||
return f"""
|
||||
Project domain: {project_config.domain}
|
||||
@@ -259,14 +289,58 @@ Rules:
|
||||
- Use only ontology predicates when possible.
|
||||
- If object is a simple value like price, put it in object_value and leave object_name/object_type null.
|
||||
- If unsure, lower confidence instead of inventing.
|
||||
- Extract at most 20 entities and 30 claims.
|
||||
- Extract at most 10 entities and 15 claims.
|
||||
- For perfume, prioritize name, brand, top/middle/base notes, accords, mood, season, occasion, price, review keywords.
|
||||
- Skip navigation, cart, coupon, pagination, login, and policy boilerplate unless it contains product facts.
|
||||
|
||||
Page text:
|
||||
{clipped_text}
|
||||
""".strip()
|
||||
|
||||
|
||||
def prepare_page_text_for_prompt(page_text: str, char_limit: int) -> str:
|
||||
noisy_terms = {
|
||||
"first page",
|
||||
"previous page",
|
||||
"next page",
|
||||
"last page",
|
||||
"add to cart",
|
||||
"cart",
|
||||
"checkout",
|
||||
"coupon",
|
||||
"login",
|
||||
"sign in",
|
||||
"privacy policy",
|
||||
"terms",
|
||||
"review write",
|
||||
"all reviews",
|
||||
"first",
|
||||
"previous",
|
||||
"next",
|
||||
"last",
|
||||
}
|
||||
lines = []
|
||||
for raw in page_text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
lowered = line.lower()
|
||||
if lowered in noisy_terms:
|
||||
continue
|
||||
if line.isdigit():
|
||||
continue
|
||||
if len(line) <= 2:
|
||||
continue
|
||||
lines.append(line)
|
||||
|
||||
compact = "\n".join(lines) if lines else page_text
|
||||
if len(compact) <= char_limit:
|
||||
return compact
|
||||
head_len = int(char_limit * 0.7)
|
||||
tail_len = char_limit - head_len
|
||||
return f"{compact[:head_len]}\n...\n{compact[-tail_len:]}"
|
||||
|
||||
|
||||
def extraction_response_format() -> dict[str, Any]:
|
||||
return {
|
||||
"type": "json_schema",
|
||||
|
||||
Reference in New Issue
Block a user