Corin1998 commited on
Commit
086b0cd
·
verified ·
1 Parent(s): 97c7e20

Update rag/ingest.py

Browse files
Files changed (1) hide show
  1. rag/ingest.py +35 -291
rag/ingest.py CHANGED
@@ -1,301 +1,45 @@
1
  # rag/ingest.py
2
- """
3
- EDINET/アップロードPDFの取り込み(RAGインデックス投入)ユーティリティ
4
-
5
- - ingest_edinet_for_company(edinet_code, date)
6
- 指定日の EDINET 公開一覧から該当企業の docID を探し、PDFを取得してインデックスへ投入
7
- - ingest_pdf_bytes(title, source_url, pdf_bytes)
8
- アップロード等のPDFバイト列をそのまま投入
9
- - download_edinet_pdf(doc_id)
10
- EDINET API から docID の PDF(=type=1) をダウンロード
11
-
12
- 依存:
13
- - irpr.deps.add_to_index …… ベクトル化+永続化
14
- - pymupdf (fitz) …… PDF → テキスト抽出
15
- - requests …… EDINET API 呼び出し
16
- """
17
-
18
  from __future__ import annotations
19
-
20
- import io
21
- import os
22
- import re
23
- import time
24
- import json
25
- import math
26
- import datetime as dt
27
- from typing import List, Dict, Optional
28
-
29
- import requests
30
  import fitz # PyMuPDF
31
-
32
  from irpr.deps import add_to_index
33
 
34
- # =============================================================================
35
- # EDINET API
36
- # =============================================================================
37
-
38
- EDINET_API_LIST = "https://disclosure.edinet-fsa.go.jp/api/v2/documents.json"
39
- EDINET_API_DOC = "https://disclosure.edinet-fsa.go.jp/api/v2/documents/{doc_id}"
40
-
41
- # 代表的なドキュメント種別(必要に応じて追加)
42
- # 120: 有価証券報告書, 130: 四半期報告書, 140: 半期報告書, 150: 臨時報告書
43
- # 160: 参照書類, 170: 訂正有価証券報告書, 180: 訂正四半期報告書, etc.
44
- PREFERRED_DOC_TYPES = {
45
- "120", "130", "140", "150", "170", "180", "350", "360", "370", "380"
46
- }
47
-
48
-
49
- def _http_get(url: str, *, headers: Optional[dict] = None, params: Optional[dict] = None,
50
- timeout: int = 60) -> requests.Response:
51
- """GET with simple retry."""
52
- last = None
53
- for _ in range(3):
54
- try:
55
- r = requests.get(url, headers=headers, params=params, timeout=timeout)
56
- if r.status_code >= 500:
57
- time.sleep(1.2)
58
- last = r
59
- continue
60
- return r
61
- except Exception as e:
62
- last = e
63
- time.sleep(1.2)
64
- if isinstance(last, requests.Response):
65
- return last
66
- raise RuntimeError(f"GET failed for {url}: {last!r}")
67
-
68
-
69
- def list_edinet_docs_for_date(date: str) -> List[dict]:
70
- """
71
- 指定日の EDINET 公開一覧(JSON)を取得。
72
- API仕様上、日付は必須・1日単位(時刻は不可)。
73
- """
74
- params = {"date": date, "type": 2} # type=2 → JSON(一覧)
75
- r = _http_get(EDINET_API_LIST, params=params)
76
- if r.status_code != 200:
77
- raise RuntimeError(f"EDINET list error: {r.status_code} {r.text[:200]}")
78
- try:
79
- data = r.json()
80
- except json.JSONDecodeError:
81
- # まれにCSVが返るケースを回避(公式はJSONだが保険)
82
- raise RuntimeError("EDINET list: JSON decode error")
83
- return data.get("results", []) or []
84
-
85
-
86
- def find_company_doc_ids(edinet_code: str, date: str, search_window_days: int = 2) -> List[dict]:
87
- """
88
- 指定日の前後 search_window_days 日をゆるく探索し、
89
- edinetCode が一致する結果を返す(スコア順:優先種別→新しい日付)。
90
- """
91
- base = dt.datetime.strptime(date, "%Y-%m-%d").date()
92
- candidates: List[dict] = []
93
-
94
- for off in range(-search_window_days, search_window_days + 1):
95
- d = (base + dt.timedelta(days=off)).isoformat()
96
- try:
97
- rows = list_edinet_docs_for_date(d)
98
- except Exception:
99
- continue
100
- for row in rows:
101
- if (row.get("edinetCode") or "").upper() == edinet_code.upper():
102
- # スコアリング:好ましいdocTypeを優先
103
- dtc = str(row.get("docTypeCode") or "")
104
- score = 100 if dtc in PREFERRED_DOC_TYPES else 0
105
- # さらに当日日付に近いほど加点(0日差=+20, 1日差=+15, …)
106
- score += max(0, 20 - 5 * abs(off))
107
- row["_score"] = score
108
- row["_date"] = d
109
- candidates.append(row)
110
-
111
- # スコア降順、提出時間の降順でソート
112
- candidates.sort(key=lambda x: (x.get("_score", 0), x.get("submitDateTime", "")), reverse=True)
113
- return candidates
114
-
115
-
116
- def download_edinet_pdf(doc_id: str) -> Optional[bytes]:
117
- """
118
- EDINET の docID から PDF バイト列を取得。
119
- 環境変数 EDINET_API_KEY が必要です。
120
- - URL 文字列が渡された場合はそのまま GET します(簡易対応)。
121
- """
122
- if doc_id.startswith("http://") or doc_id.startswith("https://"):
123
- r = _http_get(doc_id)
124
- return r.content if r.status_code == 200 else None
125
-
126
- api_key = os.environ.get("EDINET_API_KEY")
127
- if not api_key:
128
- # APIキー未設定の場合は UI 側でアップロードを利用してください
129
- raise RuntimeError("EDINET_API_KEY is not set")
130
-
131
- url = EDINET_API_DOC.format(doc_id=doc_id)
132
- headers = {"X-API-KEY": api_key}
133
- # type=1 が PDF
134
- r = _http_get(url, headers=headers, params={"type": 1})
135
- if r.status_code != 200:
136
- return None
137
- return r.content
138
-
139
-
140
- def ingest_edinet_for_company(edinet_code: str, date: str, max_docs: int = 2) -> int:
141
- """
142
- 会社コード+日付で EDINET doc を探し、PDFを取り込んでチャンク投入。
143
- 返値は投入したチャンク数。
144
- """
145
- if not edinet_code or not date:
146
- raise ValueError("edinet_code と date は必須です(dateはYYYY-MM-DD)")
147
-
148
- found = find_company_doc_ids(edinet_code, date, search_window_days=2)
149
- if not found:
150
- return 0
151
-
152
- consumed = 0
153
- for row in found[:max_docs]:
154
- doc_id = row.get("docID") or row.get("docId")
155
- if not doc_id:
156
- continue
157
- title = (row.get("title") or row.get("docDescription") or "edinet").strip()
158
- pdf = download_edinet_pdf(doc_id)
159
- if not pdf:
160
- continue
161
-
162
- text = pdf_to_text(pdf)
163
- if not text.strip():
164
- continue
165
 
166
- recs = []
167
- for j, chunk in enumerate(chunk_text(text)):
168
- recs.append({
169
- "text": chunk,
170
- "source_url": f"/proxy/edinet/{doc_id}",
 
 
 
 
 
 
 
171
  "title": title,
 
172
  "doc_id": doc_id,
173
- "chunk_id": f"{doc_id}-{j:04d}",
174
  })
175
- if recs:
176
- add_to_index(recs)
177
- consumed += len(recs)
178
-
179
- return consumed
180
-
181
-
182
- # =============================================================================
183
- # アップロードPDFの取り込み
184
- # =============================================================================
185
-
186
- def ingest_pdf_bytes(title: str, source_url: str, pdf_bytes: bytes) -> int:
187
- """
188
- ローカル/アップロードPDFをインデックスへ投入。
189
- """
190
- text = pdf_to_text(pdf_bytes)
191
- if not text.strip():
192
- return 0
193
- recs = []
194
- for j, chunk in enumerate(chunk_text(text)):
195
- recs.append({
196
- "text": chunk,
197
- "source_url": source_url or "upload",
198
- "title": title or "upload",
199
- "doc_id": f"upload:{title}",
200
- "chunk_id": f"upload-{j:04d}"
201
- })
202
- if recs:
203
- add_to_index(recs)
204
- return len(recs)
205
-
206
-
207
- # =============================================================================
208
- # PDF → テキスト抽出
209
- # =============================================================================
210
-
211
- def pdf_to_text(pdf_bytes: bytes) -> str:
212
- """
213
- PyMuPDF でプレーンテキスト抽出。日本語でも高精度。
214
- 画像だけのPDFはテキストが空になることがあります(OCRは未実装)。
215
- """
216
- try:
217
- with fitz.open(stream=pdf_bytes, filetype="pdf") as doc:
218
- texts = []
219
- for page in doc:
220
- # "text" は改行付きのレイアウトテキスト、"blocks"等でも可
221
- t = page.get_text("text")
222
- texts.append(t)
223
- text = "\n\n".join(texts)
224
- # 余計な全角空白の連続などを軽く正規化
225
- text = re.sub(r"[ \t\u3000]+", " ", text)
226
- # 連続改行の整理
227
- text = re.sub(r"\n{3,}", "\n\n", text)
228
- return text.strip()
229
- except Exception:
230
- return ""
231
-
232
 
233
- # =============================================================================
234
- # テキスト分割(日本語向けゆるふわチャンク)
235
- # =============================================================================
236
-
237
- _SENT_SPLIT_RE = re.compile(r"(.*?[\.\?\!。!?]\s*)", re.S)
238
-
239
-
240
- def split_sentences(text: str) -> List[str]:
241
- """
242
- 「。!?.!?」までを1文として切り出し。末尾に句点が無い行も拾う。
243
- """
244
- parts = []
245
- pos = 0
246
- for m in _SENT_SPLIT_RE.finditer(text):
247
- parts.append(m.group(0))
248
- pos = m.end()
249
- if pos < len(text):
250
- parts.append(text[pos:])
251
- # 行単位の段落も尊重(空行で分割したい場合はここで更に加工)
252
- out = []
253
- for p in parts:
254
- p = p.strip()
255
- if p:
256
- out.extend([s for s in p.splitlines() if s.strip()])
257
- return out
258
-
259
-
260
- def chunk_text(text: str, target_chars: int = 1000, overlap_chars: int = 200) -> List[str]:
261
- """
262
- 文章境界をできるだけ保ちながら、概ね target_chars で分割。
263
- チャンク間に overlap_chars の重なりをつけ、RAGの一致率を上げる。
264
- """
265
- if not text:
266
- return []
267
-
268
- sents = split_sentences(text)
269
- chunks: List[str] = []
270
- buf: List[str] = []
271
- size = 0
272
-
273
- def flush():
274
- if not buf:
275
- return
276
- chunk = "".join(buf).strip()
277
- if chunk:
278
- chunks.append(chunk)
279
-
280
- for s in sents:
281
- if size + len(s) <= target_chars or not buf:
282
- buf.append(s)
283
- size += len(s)
284
- continue
285
- # いったん確定
286
- flush()
287
- # オーバーラップ確保
288
- tail = []
289
- remain = overlap_chars
290
- # 後ろから文を足していく
291
- for t in reversed(buf):
292
- if remain <= 0:
293
- break
294
- tail.append(t)
295
- remain -= len(t)
296
- tail.reverse()
297
- buf = tail + [s]
298
- size = sum(len(x) for x in buf)
299
-
300
- flush()
301
- return chunks
 
1
  # rag/ingest.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  from __future__ import annotations
3
+ import io, uuid
4
+ from typing import List
 
 
 
 
 
 
 
 
 
5
  import fitz # PyMuPDF
 
6
  from irpr.deps import add_to_index
7
 
8
+ def _split_text(text: str, chunk_size=800, overlap=150) -> List[str]:
9
+ text = (text or "").strip()
10
+ if not text:
11
+ return []
12
+ chunks = []
13
+ i = 0
14
+ while i < len(text):
15
+ chunk = text[i:i+chunk_size]
16
+ chunks.append(chunk)
17
+ i += chunk_size - overlap
18
+ if i < 0 or i >= len(text):
19
+ break
20
+ return chunks
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
+ def ingest_pdf_bytes(title: str, source_url: str, pdf_bytes: bytes) -> int:
23
+ doc = fitz.open(stream=io.BytesIO(pdf_bytes), filetype="pdf")
24
+ all_chunks = []
25
+ doc_id = str(uuid.uuid4())
26
+ for page_no in range(doc.page_count):
27
+ page = doc.load_page(page_no)
28
+ raw = page.get_text("text")
29
+ # ページ番号などを付与しておく
30
+ page_text = f"[p.{page_no+1}] {raw}".strip()
31
+ for j, ch in enumerate(_split_text(page_text, 900, 150)):
32
+ all_chunks.append({
33
+ "text": ch,
34
  "title": title,
35
+ "source_url": source_url,
36
  "doc_id": doc_id,
37
+ "chunk_id": f"{page_no+1}-{j+1}",
38
  })
39
+ doc.close()
40
+ return add_to_index(all_chunks)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
+ # ---- EDINET ダミー実装(OpenAI専用版では未サポート)----
43
+ def ingest_edinet_for_company(edinet_code: str, date: str) -> int:
44
+ # ここでは何もしない(将来実装用の置き場所)
45
+ return 0