| """ |
| OpenIE extraction (NER + triple extraction) using QAFD-RAG's LLM functions. |
| |
| Follows the original openie_openai.py logic but calls the async LLM wrappers |
| from ``QAFD-RAG/src/llm.py`` synchronously via ``asyncio.run``. |
| """ |
|
|
| import asyncio |
| import json |
| import logging |
| import re |
| from concurrent.futures import ThreadPoolExecutor, as_completed |
| from dataclasses import dataclass |
| from typing import Dict, Any, List, Tuple, TypedDict, Callable |
|
|
| from tqdm import tqdm |
|
|
| from .prompts import make_ner_messages, make_triple_messages |
| from .utils import ( |
| NerRawOutput, |
| TripleRawOutput, |
| fix_broken_generated_json, |
| filter_invalid_triples, |
| ) |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class ChunkInfo(TypedDict): |
| num_tokens: int |
| content: str |
|
|
|
|
| def _run_sync(coro): |
| """Run async coroutine from sync context.""" |
| try: |
| loop = asyncio.get_running_loop() |
| except RuntimeError: |
| loop = None |
|
|
| if loop is not None and loop.is_running(): |
| import concurrent.futures |
| with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: |
| return pool.submit(asyncio.run, coro).result() |
| else: |
| return asyncio.run(coro) |
|
|
|
|
| def _extract_ner_from_response(response_text: str) -> List[str]: |
| pattern = r'\{[^{}]*"named_entities"\s*:\s*\[[^\]]*\][^{}]*\}' |
| match = re.search(pattern, response_text, re.DOTALL) |
| if match is None: |
| return [] |
| try: |
| return eval(match.group())["named_entities"] |
| except Exception: |
| return [] |
|
|
|
|
| def _extract_triples_from_response(response_text: str) -> List[List[str]]: |
| pattern = r'\{[^{}]*"triples"\s*:\s*\[[^\]]*\][^{}]*\}' |
| match = re.search(pattern, response_text, re.DOTALL) |
| if match is None: |
| return [] |
| try: |
| return eval(match.group())["triples"] |
| except Exception: |
| return [] |
|
|
|
|
| class OpenIE: |
| """Synchronous OpenIE using QAFD-RAG's async LLM function. |
| |
| Parameters |
| ---------- |
| llm_func : callable |
| An async function with the signature:: |
| |
| async def llm_func(prompt, system_prompt=None, |
| history_messages=[], **kwargs) -> str |
| |
| Typically one of the ``gpt_*_complete`` helpers from ``src/llm.py``. |
| """ |
|
|
| def __init__(self, llm_func: Callable): |
| self.llm_func = llm_func |
|
|
| def _call_llm(self, messages: List[Dict[str, str]]) -> str: |
| """Convert chat messages to a single LLM call.""" |
| system_prompt = None |
| history = [] |
| user_prompt = "" |
| for msg in messages: |
| if msg["role"] == "system": |
| system_prompt = msg["content"] |
| elif msg["role"] == "assistant": |
| history.append(msg) |
| elif msg["role"] == "user": |
| |
| if user_prompt: |
| history.append({"role": "user", "content": user_prompt}) |
| user_prompt = msg["content"] |
|
|
| return _run_sync( |
| self.llm_func( |
| prompt=user_prompt, |
| system_prompt=system_prompt, |
| history_messages=history, |
| max_tokens=2048, |
| ) |
| ) |
|
|
| |
| def ner(self, chunk_key: str, passage: str) -> NerRawOutput: |
| messages = make_ner_messages(passage) |
| raw_response = "" |
| metadata: Dict[str, Any] = {} |
| try: |
| raw_response = self._call_llm(messages) |
| real_response = fix_broken_generated_json(raw_response) |
| extracted = _extract_ner_from_response(real_response) |
| unique_entities = list(dict.fromkeys(extracted)) |
| except Exception as e: |
| logger.warning(f"NER error for chunk {chunk_key}: {e}") |
| metadata["error"] = str(e) |
| return NerRawOutput( |
| chunk_id=chunk_key, |
| response=raw_response, |
| unique_entities=[], |
| metadata=metadata, |
| ) |
|
|
| return NerRawOutput( |
| chunk_id=chunk_key, |
| response=raw_response, |
| unique_entities=unique_entities, |
| metadata=metadata, |
| ) |
|
|
| |
| def triple_extraction( |
| self, chunk_key: str, passage: str, named_entities: List[str] |
| ) -> TripleRawOutput: |
| messages = make_triple_messages(passage, named_entities) |
| raw_response = "" |
| metadata: Dict[str, Any] = {} |
| try: |
| raw_response = self._call_llm(messages) |
| real_response = fix_broken_generated_json(raw_response) |
| extracted = _extract_triples_from_response(real_response) |
| triplets = filter_invalid_triples(triples=extracted) |
| except Exception as e: |
| logger.warning(f"Triple extraction error for chunk {chunk_key}: {e}") |
| metadata["error"] = str(e) |
| return TripleRawOutput( |
| chunk_id=chunk_key, |
| response=raw_response, |
| metadata=metadata, |
| triples=[], |
| ) |
|
|
| return TripleRawOutput( |
| chunk_id=chunk_key, |
| response=raw_response, |
| metadata=metadata, |
| triples=triplets, |
| ) |
|
|
| |
| def openie(self, chunk_key: str, passage: str) -> Dict[str, Any]: |
| ner_output = self.ner(chunk_key=chunk_key, passage=passage) |
| triple_output = self.triple_extraction( |
| chunk_key=chunk_key, |
| passage=passage, |
| named_entities=ner_output.unique_entities, |
| ) |
| return {"ner": ner_output, "triplets": triple_output} |
|
|
| |
| def batch_openie( |
| self, chunks: Dict[str, dict] |
| ) -> Tuple[Dict[str, NerRawOutput], Dict[str, TripleRawOutput]]: |
| """Run NER + triple extraction over all chunks using multithreading. |
| |
| Parameters |
| ---------- |
| chunks : dict |
| Mapping ``chunk_hash_id -> {"content": text, ...}``. |
| |
| Returns |
| ------- |
| (ner_dict, triple_dict) |
| """ |
| chunk_passages = {k: v["content"] for k, v in chunks.items()} |
|
|
| |
| ner_results: List[NerRawOutput] = [] |
|
|
| with ThreadPoolExecutor() as executor: |
| ner_futures = { |
| executor.submit(self.ner, ckey, passage): ckey |
| for ckey, passage in chunk_passages.items() |
| } |
| for future in tqdm( |
| as_completed(ner_futures), total=len(ner_futures), desc="NER" |
| ): |
| ner_results.append(future.result()) |
|
|
| |
| triple_results: List[TripleRawOutput] = [] |
|
|
| with ThreadPoolExecutor() as executor: |
| re_futures = { |
| executor.submit( |
| self.triple_extraction, |
| nr.chunk_id, |
| chunk_passages[nr.chunk_id], |
| nr.unique_entities, |
| ): nr.chunk_id |
| for nr in ner_results |
| } |
| for future in tqdm( |
| as_completed(re_futures), |
| total=len(re_futures), |
| desc="Triple extraction", |
| ): |
| triple_results.append(future.result()) |
|
|
| ner_dict = {r.chunk_id: r for r in ner_results} |
| triple_dict = {r.chunk_id: r for r in triple_results} |
| return ner_dict, triple_dict |
|
|