You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

claim_extractor.py 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. #
  2. # Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
  3. #
  4. # Licensed under the Apache License, Version 2.0 (the "License");
  5. # you may not use this file except in compliance with the License.
  6. # You may obtain a copy of the License at
  7. #
  8. # http://www.apache.org/licenses/LICENSE-2.0
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. #
  16. """
  17. Reference:
  18. - [graphrag](https://github.com/microsoft/graphrag)
  19. """
  20. import argparse
  21. import json
  22. import logging
  23. import re
  24. import traceback
  25. from dataclasses import dataclass
  26. from typing import Any
  27. import tiktoken
  28. from graphrag.claim_prompt import CLAIM_EXTRACTION_PROMPT, CONTINUE_PROMPT, LOOP_PROMPT
  29. from rag.llm.chat_model import Base as CompletionLLM
  30. from graphrag.utils import ErrorHandlerFn, perform_variable_replacements
  31. DEFAULT_TUPLE_DELIMITER = "<|>"
  32. DEFAULT_RECORD_DELIMITER = "##"
  33. DEFAULT_COMPLETION_DELIMITER = "<|COMPLETE|>"
  34. CLAIM_MAX_GLEANINGS = 1
  35. log = logging.getLogger(__name__)
  36. @dataclass
  37. class ClaimExtractorResult:
  38. """Claim extractor result class definition."""
  39. output: list[dict]
  40. source_docs: dict[str, Any]
  41. class ClaimExtractor:
  42. """Claim extractor class definition."""
  43. _llm: CompletionLLM
  44. _extraction_prompt: str
  45. _summary_prompt: str
  46. _output_formatter_prompt: str
  47. _input_text_key: str
  48. _input_entity_spec_key: str
  49. _input_claim_description_key: str
  50. _tuple_delimiter_key: str
  51. _record_delimiter_key: str
  52. _completion_delimiter_key: str
  53. _max_gleanings: int
  54. _on_error: ErrorHandlerFn
  55. def __init__(
  56. self,
  57. llm_invoker: CompletionLLM,
  58. extraction_prompt: str | None = None,
  59. input_text_key: str | None = None,
  60. input_entity_spec_key: str | None = None,
  61. input_claim_description_key: str | None = None,
  62. input_resolved_entities_key: str | None = None,
  63. tuple_delimiter_key: str | None = None,
  64. record_delimiter_key: str | None = None,
  65. completion_delimiter_key: str | None = None,
  66. encoding_model: str | None = None,
  67. max_gleanings: int | None = None,
  68. on_error: ErrorHandlerFn | None = None,
  69. ):
  70. """Init method definition."""
  71. self._llm = llm_invoker
  72. self._extraction_prompt = extraction_prompt or CLAIM_EXTRACTION_PROMPT
  73. self._input_text_key = input_text_key or "input_text"
  74. self._input_entity_spec_key = input_entity_spec_key or "entity_specs"
  75. self._tuple_delimiter_key = tuple_delimiter_key or "tuple_delimiter"
  76. self._record_delimiter_key = record_delimiter_key or "record_delimiter"
  77. self._completion_delimiter_key = (
  78. completion_delimiter_key or "completion_delimiter"
  79. )
  80. self._input_claim_description_key = (
  81. input_claim_description_key or "claim_description"
  82. )
  83. self._input_resolved_entities_key = (
  84. input_resolved_entities_key or "resolved_entities"
  85. )
  86. self._max_gleanings = (
  87. max_gleanings if max_gleanings is not None else CLAIM_MAX_GLEANINGS
  88. )
  89. self._on_error = on_error or (lambda _e, _s, _d: None)
  90. # Construct the looping arguments
  91. encoding = tiktoken.get_encoding(encoding_model or "cl100k_base")
  92. yes = encoding.encode("YES")
  93. no = encoding.encode("NO")
  94. self._loop_args = {"logit_bias": {yes[0]: 100, no[0]: 100}, "max_tokens": 1}
  95. def __call__(
  96. self, inputs: dict[str, Any], prompt_variables: dict | None = None
  97. ) -> ClaimExtractorResult:
  98. """Call method definition."""
  99. if prompt_variables is None:
  100. prompt_variables = {}
  101. texts = inputs[self._input_text_key]
  102. entity_spec = str(inputs[self._input_entity_spec_key])
  103. claim_description = inputs[self._input_claim_description_key]
  104. resolved_entities = inputs.get(self._input_resolved_entities_key, {})
  105. source_doc_map = {}
  106. prompt_args = {
  107. self._input_entity_spec_key: entity_spec,
  108. self._input_claim_description_key: claim_description,
  109. self._tuple_delimiter_key: prompt_variables.get(self._tuple_delimiter_key)
  110. or DEFAULT_TUPLE_DELIMITER,
  111. self._record_delimiter_key: prompt_variables.get(self._record_delimiter_key)
  112. or DEFAULT_RECORD_DELIMITER,
  113. self._completion_delimiter_key: prompt_variables.get(
  114. self._completion_delimiter_key
  115. )
  116. or DEFAULT_COMPLETION_DELIMITER,
  117. }
  118. all_claims: list[dict] = []
  119. for doc_index, text in enumerate(texts):
  120. document_id = f"d{doc_index}"
  121. try:
  122. claims = self._process_document(prompt_args, text, doc_index)
  123. all_claims += [
  124. self._clean_claim(c, document_id, resolved_entities) for c in claims
  125. ]
  126. source_doc_map[document_id] = text
  127. except Exception as e:
  128. log.exception("error extracting claim")
  129. self._on_error(
  130. e,
  131. traceback.format_exc(),
  132. {"doc_index": doc_index, "text": text},
  133. )
  134. continue
  135. return ClaimExtractorResult(
  136. output=all_claims,
  137. source_docs=source_doc_map,
  138. )
  139. def _clean_claim(
  140. self, claim: dict, document_id: str, resolved_entities: dict
  141. ) -> dict:
  142. # clean the parsed claims to remove any claims with status = False
  143. obj = claim.get("object_id", claim.get("object"))
  144. subject = claim.get("subject_id", claim.get("subject"))
  145. # If subject or object in resolved entities, then replace with resolved entity
  146. obj = resolved_entities.get(obj, obj)
  147. subject = resolved_entities.get(subject, subject)
  148. claim["object_id"] = obj
  149. claim["subject_id"] = subject
  150. claim["doc_id"] = document_id
  151. return claim
  152. def _process_document(
  153. self, prompt_args: dict, doc, doc_index: int
  154. ) -> list[dict]:
  155. record_delimiter = prompt_args.get(
  156. self._record_delimiter_key, DEFAULT_RECORD_DELIMITER
  157. )
  158. completion_delimiter = prompt_args.get(
  159. self._completion_delimiter_key, DEFAULT_COMPLETION_DELIMITER
  160. )
  161. variables = {
  162. self._input_text_key: doc,
  163. **prompt_args,
  164. }
  165. text = perform_variable_replacements(self._extraction_prompt, variables=variables)
  166. gen_conf = {"temperature": 0.5}
  167. results = self._llm.chat(text, [], gen_conf)
  168. claims = results.strip().removesuffix(completion_delimiter)
  169. history = [{"role": "system", "content": text}, {"role": "assistant", "content": results}]
  170. # Repeat to ensure we maximize entity count
  171. for i in range(self._max_gleanings):
  172. text = perform_variable_replacements(CONTINUE_PROMPT, history=history, variables=variables)
  173. history.append({"role": "user", "content": text})
  174. extension = self._llm.chat("", history, gen_conf)
  175. claims += record_delimiter + extension.strip().removesuffix(
  176. completion_delimiter
  177. )
  178. # If this isn't the last loop, check to see if we should continue
  179. if i >= self._max_gleanings - 1:
  180. break
  181. history.append({"role": "assistant", "content": extension})
  182. history.append({"role": "user", "content": LOOP_PROMPT})
  183. continuation = self._llm.chat("", history, self._loop_args)
  184. if continuation != "YES":
  185. break
  186. result = self._parse_claim_tuples(claims, prompt_args)
  187. for r in result:
  188. r["doc_id"] = f"{doc_index}"
  189. return result
  190. def _parse_claim_tuples(
  191. self, claims: str, prompt_variables: dict
  192. ) -> list[dict[str, Any]]:
  193. """Parse claim tuples."""
  194. record_delimiter = prompt_variables.get(
  195. self._record_delimiter_key, DEFAULT_RECORD_DELIMITER
  196. )
  197. completion_delimiter = prompt_variables.get(
  198. self._completion_delimiter_key, DEFAULT_COMPLETION_DELIMITER
  199. )
  200. tuple_delimiter = prompt_variables.get(
  201. self._tuple_delimiter_key, DEFAULT_TUPLE_DELIMITER
  202. )
  203. def pull_field(index: int, fields: list[str]) -> str | None:
  204. return fields[index].strip() if len(fields) > index else None
  205. result: list[dict[str, Any]] = []
  206. claims_values = (
  207. claims.strip().removesuffix(completion_delimiter).split(record_delimiter)
  208. )
  209. for claim in claims_values:
  210. claim = claim.strip().removeprefix("(").removesuffix(")")
  211. claim = re.sub(r".*Output:", "", claim)
  212. # Ignore the completion delimiter
  213. if claim == completion_delimiter:
  214. continue
  215. claim_fields = claim.split(tuple_delimiter)
  216. o = {
  217. "subject_id": pull_field(0, claim_fields),
  218. "object_id": pull_field(1, claim_fields),
  219. "type": pull_field(2, claim_fields),
  220. "status": pull_field(3, claim_fields),
  221. "start_date": pull_field(4, claim_fields),
  222. "end_date": pull_field(5, claim_fields),
  223. "description": pull_field(6, claim_fields),
  224. "source_text": pull_field(7, claim_fields),
  225. "doc_id": pull_field(8, claim_fields),
  226. }
  227. if any([not o["subject_id"], not o["object_id"], o["subject_id"].lower() == "none", o["object_id"] == "none"]):
  228. continue
  229. result.append(o)
  230. return result
  231. if __name__ == "__main__":
  232. parser = argparse.ArgumentParser()
  233. parser.add_argument('-t', '--tenant_id', default=False, help="Tenant ID", action='store', required=True)
  234. parser.add_argument('-d', '--doc_id', default=False, help="Document ID", action='store', required=True)
  235. args = parser.parse_args()
  236. from api.db import LLMType
  237. from api.db.services.llm_service import LLMBundle
  238. from api.settings import retrievaler
  239. ex = ClaimExtractor(LLMBundle(args.tenant_id, LLMType.CHAT))
  240. docs = [d["content_with_weight"] for d in retrievaler.chunk_list(args.doc_id, args.tenant_id, max_count=12, fields=["content_with_weight"])]
  241. info = {
  242. "input_text": docs,
  243. "entity_specs": "organization, person",
  244. "claim_description": ""
  245. }
  246. claim = ex(info)
  247. print(json.dumps(claim.output, ensure_ascii=False, indent=2))