Skip to content

Revert "Revert "Develop" "#77

Merged
moonhwoo merged 3 commits into
mainfrom
revert-76-revert-75-develop
Apr 7, 2026
Merged

Revert "Revert "Develop" "#77
moonhwoo merged 3 commits into
mainfrom
revert-76-revert-75-develop

Conversation

@moonhwoo

@moonhwoo moonhwoo commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Reverts #76
revert 한거를 다시 revert -> 즉 develop ->main 내용 추가

Summary by CodeRabbit

  • 새로운 기능

    • 문서 업로드·처리용 HTTP API 및 백그라운드 문서 처리 워크플로 추가
    • 실시간 음성 Q&A WebSocket 스트리밍(스트리밍 TTS/STT 포함) 및 음성·텍스트 Q&A 엔드포인트 추가
    • 헬스 체크 엔드포인트 및 앱 진입점 등록
  • 개선 사항

    • PDF 파싱·청킹 및 검색요약·임베딩 생성 로직 개선(문단/문장 기반 분할, 요약 모델 업데이트)
    • 질의 분기·페일백 기반의 다단계 라우팅과 실행 흐름 트레이스 강화
    • STT/TTS 및 LLM 호출의 스트리밍·오류 처리 개선
  • 기타

    • 저장소 무시 규칙 정리, 테스트/시드 데이터 및 유틸 스크립트 추가

@coderabbitai

coderabbitai Bot commented Apr 6, 2026

Copy link
Copy Markdown

Walkthrough

대규모 구조 변경: FastAPI를 라우터별 모듈로 분리하고 중앙 의존성/파이프라인을 도입했으며, 문서 처리(다운로드→청킹→요약→임베딩→DB), WebSocket 기반 STT→QA→TTS 스트리밍, 및 의도 랭킹 기반 폴백 그래프 재설계를 추가/개편했습니다.

Changes

Cohort / File(s) Summary
앱 진입점 & 라우터
app/main.py, app/api/health.py, app/api/qa.py, app/api/document.py, app/api/document_legacy.py, app/api/websocket.py
FastAPI 앱 생성 및 라우터 분리: 헬스, QA(텍스트/음성/내부), 문서 처리(업로드/legacy/process)와 WebSocket 스트리밍 엔드포인트 추가. 인증·SSRF 검사, BackgroundTasks 예약, 스트리밍/상태 메시지 구현 포함.
중앙 의존성 및 파이프라인
app/core/dependencies.py, app/core/services/pipeline.py, app/core/services/llm_openai.py
공유 클라이언트(STT/TTS/Embedder/LLM/RAG) 중앙 초기화, sync/async OpenAI 클라이언트 분리, Voice/Text/StreamingVoicePipeline 및 traced 래퍼 추가, 스트리밍 응답/문장 단위 버퍼링 지원.
문서 처리 서비스 & DB 파이프라인
app/core/services/document_processor.py, app/core/DB/PDF2db_v2.py
원격 PDF 다운로드→마크다운 변환→섹션 기반 청킹(문장 오버랩)→GPT 요약/키워드→임베딩 생성→트랜잭션으로 tb_doc_chunk_v2 갱신. Core 콜백(처리/완료/실패) 재시도 로직 포함.
WebSocket 스트리밍 QA & TTS/STT 변경
app/api/websocket.py, app/core/services/tts_google.py, app/core/services/stt_google.py, app/core/services/pipeline.py
WS로 오디오 프레임 수신→실시간 STT(중간/최종 전사 전송)→텍스트 QA 호출→문장 단위 TTS 합성(스트리밍 옵션)→클라이언트에 오디오 청크·상태·지연 보고. STT/TTS 모델/매개변수 명시 및 스트리밍 TTS 클래스를 추가.
그래프 재설계 및 노드/라우터 변경
app/graph/graph_builder.py, app/graph/state.py, app/graph/routers/*, app/graph/nodes/...
단일 게이트·infotype 기반 흐름 제거 → 의도 랭킹 기반 폴백 플로우 도입(fallback_dispatch/router 등). 여러 기존 노드 제거(예: direct_llm, infotype, query_rewrite, map_tool, smalltalk 등) 및 신규 노드 추가(예: event, struct_db, smalltalk, fetch_places), trace["_flow"] 누적 추적 확장.
인프라·유틸·스크립트·설정
.gitignore, .coderabbit.yaml, app/core/ws_stt_test.py, audio/m4a.py, record.py, seed_gyeongbokgung_test.sql
무시 패턴 확장(.idea, *.json, md 제외 README.md 등), WS 테스트 클라이언트 업데이트(티밍/tts 저장), 오디오 변환 스크립트 추가, 녹음 파일명 변경, DB 시드 스크립트 추가.

Sequence Diagram(s)

sequenceDiagram
    participant Client as 클라이언트
    participant WS as WebSocket 엔드포인트
    participant STT as STT 서비스
    participant TextQA as Text QA 파이프라인
    participant LLM as LLM
    participant TTS as TTS 서비스
    participant Core as Spring Core

    Client->>WS: {type:"start"} + 오디오 스트리밍
    activate WS
    WS->>STT: PCM 오디오 프레임 스트림
    STT-->>WS: interim / final transcript
    WS-->>Client: stt_start / interim / final

    WS->>TextQA: 최종 전사 (query) + site_id + mascot?
    activate TextQA
    TextQA->>LLM: RAG/LLM 호출 (검색+생성)
    LLM-->>TextQA: 답변 텍스트
    deactivate TextQA

    loop 문장별
      WS->>TTS: 문장 텍스트
      TTS-->>WS: MP3 청크 (base64)
      WS-->>Client: tts_chunk
    end

    WS-->>Client: final_text / latency_summary / done
    deactivate WS
Loading
sequenceDiagram
    participant API as /v1/documents/process
    participant Validator as SSRF 검증
    participant Downloader as PDF 다운로드
    participant Processor as document_processor
    participant Converter as PDF→Markdown (PDF2db_v2)
    participant Chunker as 청킹/요약/임베딩
    participant DB as PostgreSQL
    participant CoreSvc as Spring Core

    API->>Validator: storage_url 검증
    Validator-->>API: OK / 400

    API->>Processor: 백그라운드 작업 스케줄
    Processor->>Downloader: GET storage_url
    Downloader-->>Processor: pdf bytes

    Processor->>Converter: PDF → Markdown → 섹션
    Converter-->>Chunker: section texts
    Chunker-->>Processor: summaries, keywords, embeddings

    Processor->>DB: 트랜잭션: 기존 청크 삭제 → 새 청크 삽입
    DB-->>Processor: 저장 완료

    Processor->>CoreSvc: PATCH status=COMPLETED (retry on failure)
    CoreSvc-->>Processor: 응답
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • Feature/router unified routing #34: 그래프 라우팅·의도 랭킹 관련 변경과 직접적인 코드 레벨 연관성.
  • Feature/event #55: WebSocket 스트리밍 및 event_node 관련 변경이 중복/연관됨.
  • Develop #75: 라우터·문서 처리·WebSocket·그래프 재구성 등 대규모 중복 변경으로 직접 관련.

Suggested reviewers

  • jundoring

Poem

🐇 "래빗의 축하"
집은 나뉘어 문을 달고, 길은 소리로 묻고
종이는 쪼개져 뜻을 모아두고
말은 텍스트 되어 다시 노래하네 🎶
작은 마스코트, 함께 길을 밝혀보자.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive PR 제목은 develop 브랜치를 main에 다시 병합하는 revert 작업의 실질적 의도를 반영하지만, 'Revert "Revert "Develop" "' 형태로 따옴표가 중첩되어 모호하고 가독성이 떨어집니다. 제목을 'Reapply develop branch changes' 또는 '변경 사항 재적용' 같이 더 명확하고 가독성 있게 수정하는 것을 권장합니다.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch revert-76-revert-75-develop

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 19

🧹 Nitpick comments (10)
record.py (1)

6-6: 파일명 하드코딩으로 연계 경로 계약이 깨질 수 있습니다.

Line 6에서 파일명을 "궁성담장길이.wav"로 고정하면, app/core/ws_stt_test.py (Line 10)의 WAV_PATH = "audio/wav/input4.wav"처럼 고정 경로/이름을 기대하는 소비 코드와 쉽게 불일치가 납니다. 기본 저장 위치와 파일명을 설정값/인자로 분리해 주세요.

제안 수정안
+from pathlib import Path
+
 SAMPLE_RATE = 16000   # Google STT 필수 설정
 DURATION    = 5       # 초 (원하는 만큼 변경)
-FILENAME    = "궁성담장길이.wav"
+OUTPUT_DIR  = Path("audio/wav")
+FILENAME    = OUTPUT_DIR / "input1.wav"

 print(f"녹음 시작... ({DURATION}초)")
 audio = sd.rec(int(DURATION * SAMPLE_RATE),
                samplerate=SAMPLE_RATE,
                channels=1,
                dtype="int16")
 sd.wait()
+OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
 sf.write(FILENAME, audio, SAMPLE_RATE, subtype="PCM_16")
 print(f"저장 완료: {FILENAME}")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@record.py` at line 6, The FILENAME constant is hardcoded ("궁성담장길이.wav") which
can break consumers expecting different paths (e.g., WAV_PATH in
app/core/ws_stt_test.py); change FILENAME to be configurable by reading from an
environment variable, a configuration object, or accepting it as a
function/class parameter and provide a sensible default, and update any callers
to pass the expected path (or read the same config) so both record.py's FILENAME
and ws_stt_test.py's WAV_PATH come from the same configurable source.
audio/m4a.py (1)

10-12: 확장자 필터 외에 파일 타입 체크를 추가하면 더 안전합니다.

Line 11 조건만으로는 .m4a 이름의 디렉터리도 매칭될 수 있습니다. os.path.isfile(input_path)를 함께 확인해 주세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@audio/m4a.py` around lines 10 - 12, The loop over os.listdir(INPUT_DIR)
currently only checks filename.lower().endswith(".m4a") which can match
directories named *.m4a; update the condition to also verify the path is a
regular file by computing input_path = os.path.join(INPUT_DIR, filename) and
adding os.path.isfile(input_path) to the conditional so only real files (not
directories) are processed; ensure you reference INPUT_DIR, filename and
input_path when making the change.
app/core/services/llm_openai.py (2)

72-72: PEP 484에 따라 명시적 Optional 타입을 사용하세요.

max_tokens: int = None은 암묵적 Optional로, Ruff에서도 경고합니다.

♻️ 타입 힌트 수정
-    def chat(self, messages: list, max_tokens: int = None) -> str:
+    def chat(self, messages: list, max_tokens: int | None = None) -> str:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/core/services/llm_openai.py` at line 72, 함수 chat의 시그니처에서 암묵적 Optional인
`max_tokens: int = None`을 명시적으로 바꿔 PEP 484 규약을 따르세요: `max_tokens: Optional[int]
= None`로 변경하고 파일 상단(또는 타입 임포트 위치)에 `from typing import Optional`를 추가하여 타입이 정의되도록
하세요; 대상 심볼은 메서드 이름 `chat`을 수정하면 됩니다.

20-23: 한국어 문장 종결 패턴 검증이 필요합니다.

현재 정규식 r"(.+?[.!?]|.+?다\.|.+?요\.|.+?니다\.)" 는 기본적인 종결 어미를 포함하지만, 일부 케이스에서 의도치 않은 분리가 발생할 수 있습니다:

  • "네, 그렇습니다." → 정상 분리
  • "123.45입니다." → 숫자 내 마침표에서 잘못 분리될 수 있음

TTS 용도로 사용 시 큰 문제는 아니지만, 참고하시기 바랍니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/core/services/llm_openai.py` around lines 20 - 23, The current
sentence-splitting regex _SENTENCE_RE can split on decimal points (e.g.,
"123.45입니다.") because it treats any period as a sentence terminator; update the
pattern used by _SENTENCE_RE to avoid matching periods that are part of numeric
decimals by adding a negative lookbehind for digits (so a period is only
considered a terminator when not preceded by a digit) and/or require whitespace
or end-of-string after the terminator for Korean endings like "다.", "요.", "니다.";
modify the _SENTENCE_RE definition accordingly to use a negative lookbehind for
digits and to validate what follows the punctuation to prevent splitting inside
numbers.
app/graph/nodes/tool/fetch_places_node.py (2)

41-45: 동기 requests 호출이 비동기 파이프라인에서 블로킹될 수 있습니다.

이 노드가 asyncio.to_thread를 통해 호출되는 경우에는 문제가 없지만, 직접 async 컨텍스트에서 호출되면 이벤트 루프를 블로킹합니다. 향후 httpx 또는 aiohttp로 전환을 고려해 보세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/graph/nodes/tool/fetch_places_node.py` around lines 41 - 45, The
synchronous requests.get call (resp = requests.get(...)) blocks the event loop
in async contexts; update the fetch_places_node function to perform the HTTP
request off the event loop (e.g., wrap the blocking call in asyncio.to_thread
when using requests) or convert the node to use an async HTTP client (e.g.,
httpx.AsyncClient or aiohttp.ClientSession) and replace requests.get with an
awaitable async call to avoid blocking; ensure you keep the same params and
timeout handling and return shape so callers of fetch_places_node (or the resp
handling logic) continue to work.

68-71: 예외 처리는 적절하나, 디버깅을 위한 로깅 개선을 권장합니다.

외부 서비스 호출에서 모든 예외를 catch하여 파이프라인 실패를 방지하는 것은 올바른 접근입니다. 다만, 현재는 예외 타입만 로깅되어 문제 진단이 어려울 수 있습니다.

♻️ 에러 메시지 로깅 추가
     except Exception as e:
-        print(f"[fetch_places] 오류: {type(e).__name__}")
+        print(f"[fetch_places] 오류: {type(e).__name__}: {e}")
         trace["fetch_places"] = {"status": "error", "error": "core_places_failed"}
         return {"nearby_places": [], "trace": trace}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/graph/nodes/tool/fetch_places_node.py` around lines 68 - 71, The except
block in fetch_places (the except Exception as e handler) only prints the
exception type and sets trace to "core_places_failed"; replace the simple print
with structured logging that includes the exception message and stack trace
(e.g., use logger.exception or logger.error(..., exc_info=True)) and augment the
trace entry (trace["fetch_places"]) to include the actual error message (and
optionally a short stack/identifier) so debugging external service failures is
easier; update references in the function/method handling fetch_places to use
the module logger rather than print and keep returning the same result shape.
app/graph/nodes/answer/struct_db_node.py (2)

34-36: no_context 반환 시 emotioncategory 기본값 누락 가능성.

nearby_places가 없을 때 emotioncategory를 반환하지 않습니다. 반면 Lines 172-178의 성공 케이스에서는 이 필드들을 포함합니다. GraphState에서 emotion: strcategory: str이 필수 필드로 정의되어 있다면, 상태 일관성을 위해 기본값을 반환하는 것이 좋습니다.

♻️ 수정 제안
         if not nearby_places:
             trace["struct_db"] = {"status": "no_context", "query": text}
-            return {"answer_text": "", "check_result": "bad", "trace": trace}
+            return {
+                "answer_text": "",
+                "place_id": None,
+                "emotion": "GUIDING",
+                "category": "DIRECTION",
+                "check_result": "bad",
+                "trace": trace,
+            }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/graph/nodes/answer/struct_db_node.py` around lines 34 - 36, When
nearby_places is empty, the function returns without including required
GraphState fields emotion and category; update the early-return block in
struct_db_node.py (the branch that sets trace["struct_db"] = {"status":
"no_context", "query": text} and returns {"answer_text": "", "check_result":
"bad", "trace": trace}) to also include default values for emotion and category
(e.g., empty strings or defined defaults) so the returned state shape matches
the successful branch and GraphState's required fields.

159-161: llm_failed 반환 시에도 emotioncategory 기본값 누락.

동일하게 llm_failed 케이스에서도 일관성을 위해 기본값을 포함하는 것이 좋습니다.

♻️ 수정 제안
         if not answer_text:
             trace["struct_db"] = {"status": "llm_failed", "method": method, "error": error_msg}
-            return {"answer_text": "", "check_result": "bad", "trace": trace}
+            return {
+                "answer_text": "",
+                "place_id": None,
+                "emotion": "GUIDING",
+                "category": "DIRECTION",
+                "check_result": "bad",
+                "trace": trace,
+            }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/graph/nodes/answer/struct_db_node.py` around lines 159 - 161, The
llm_failed branch omits default emotion and category values; update the branch
in the function that sets trace["struct_db"] (the block returning
{"answer_text": "", "check_result": "bad", "trace": trace}) to include default
keys for emotion and category both in trace["struct_db"] (e.g., "emotion": "",
"category": "") and in the returned payload so the response shape matches
success cases; ensure you modify the same block that sets "status": "llm_failed"
and uses variables method and error_msg.
app/core/services/tts_google.py (1)

35-36: 빈 텍스트 폴백 메시지가 한국어로 하드코딩되어 있습니다.

language_code가 비한국어로 전달되어도 빈 텍스트 시 한국어 메시지("음성으로 변환할 내용이 없습니다.")가 사용됩니다. 다국어 지원을 고려하면 language_code에 맞는 폴백 메시지를 사용하거나, 호출자 측에서 빈 텍스트를 처리하도록 하는 것이 좋습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/core/services/tts_google.py` around lines 35 - 36, The fallback for empty
text is hardcoded to Korean; update the empty-text handling in the function that
sets `text` (in app/core/services/tts_google.py) to use `language_code` instead
of a fixed Korean string: either map common language codes to localized fallback
messages based on `language_code`, or raise/return an error so the caller
handles empty input; ensure the branch that currently does `if not text or not
text.strip(): text = "음성으로 변환할 내용이 없습니다."` uses the `language_code` variable to
select the appropriate message or propagates an error to the caller.
app/core/dependencies.py (1)

18-18: OPENAI_API_KEY 누락 시 명확한 에러 메시지 고려.

os.environ["OPENAI_API_KEY"]는 키가 없을 때 KeyError를 발생시킵니다. 모듈 임포트 시점에 실패하므로 빠른 피드백이 되지만, 더 명확한 에러 메시지를 제공하는 것이 디버깅에 도움이 됩니다.

💡 대안 제안
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
    raise ValueError("OPENAI_API_KEY environment variable is required")
client = OpenAI(api_key=api_key)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/core/dependencies.py` at line 18, The code instantiates client =
OpenAI(api_key=os.environ["OPENAI_API_KEY"]) which raises a generic KeyError on
import; change to read the key via os.getenv (e.g., api_key =
os.getenv("OPENAI_API_KEY")), validate that api_key is present and if not raise
a clear ValueError("OPENAI_API_KEY environment variable is required"), then pass
that api_key into OpenAI(...) to create client; update references to the client
variable as needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.gitignore:
- Around line 18-19: The .gitignore currently contains a broad "*.json" rule
which will exclude important config and schema files; remove the "*.json" entry
and instead explicitly ignore only credential files like
"google-credentials.json" (and any other secrets you have), or if you must keep
a wildcard, add explicit allow-lists for important files such as "package.json",
"tsconfig.json", and your API/schema/test fixture files (e.g., using negation
patterns like "!package.json" or listing "schemas/*.json") so that only
sensitive JSONs are ignored and config/artifact JSONs remain tracked.

In `@app/api/document_legacy.py`:
- Around line 60-61: The legacy endpoint reads the entire upload into memory via
await file.read() (producing pdf_bytes and file_hash); change this to stream the
upload in chunks while updating a SHA256 hasher and accumulated size, enforce
the same 50MB guard used in app/core/services/document_processor.py (or return
HTTP 413 when exceeded), and use the final hexdigest as file_hash—apply the same
chunked/hash approach to both locations where await file.read() is used to avoid
unbounded memory usage.

In `@app/api/document.py`:
- Around line 39-41: The current check lets requests through when
_INTERNAL_TOKEN is empty, causing a fail-open; change the logic in the request
validation around _INTERNAL_TOKEN and x_internal_token so that absence of
_INTERNAL_TOKEN results in rejection by default (raise HTTPException 403) unless
an explicit DEBUG/dev flag is true; when _INTERNAL_TOKEN is set require
x_internal_token to match it; ensure this replaces the current unconditional
pass and does not rely on allowlist for bypass (refer to symbols
_INTERNAL_TOKEN, x_internal_token, allowlist, and introduce/consult DEBUG).

In `@app/api/qa.py`:
- Around line 119-135: The code currently treats any result with
result["check_result"] != "good" as not found and always returns a fallback;
instead, preserve generated answer_text unless it's empty: compute answer =
result.get("answer_text", "") and set answer_found = bool(answer) (or use answer
!= "") rather than result.get("check_result") == "good"; then when building
InternalQaResponse only apply fallback_answers.get(lang2, ...) if answer is
empty, and set emotion to "GUIDING" when answer is present otherwise "SORRY";
keep other fields (placeId, language/lang2, category, answerFound) as before.

In `@app/api/websocket.py`:
- Around line 157-163: send_tts_chunks currently computes is_final by treating
the provided sentences list as the entire stream, which causes realtime mode
(where sentences are sent one-by-one) to mark every chunk as final; change
send_tts_chunks signature to accept an explicit boolean like is_last_sentence
(or is_final_override) and use that to set is_final for the last chunk, and
update the realtime call sites (the realtime branch that invokes send_tts_chunks
around the areas referenced in the diff: the caller(s) near the existing
realtime logic at the first occurrence and the other two spots mentioned) to
pass True only for the actual last sentence and False otherwise so clients
receive correct final flags.
- Around line 539-545: The exception handler that sends errors to the client
(the block around websocket.send_text in app/api/websocket.py) currently uses
str(e) which may leak sensitive upstream details; change it to return a fixed,
non-sensitive client-facing message (e.g., "An internal error occurred") and
keep the trace_id, while logging the full exception and stack trace on the
server side (use the existing logger with trace_id) instead of including str(e)
in the JSON payload; update the payload construction in the websocket.send_text
call to remove or replace the "message" value and ensure the server log captures
e and traceback together.
- Around line 273-285: The audio queue is created without a bound which can let
incoming audio accumulate indefinitely; change the creation of audio_q to use a
bounded queue (e.g., asyncio.Queue(maxsize=YOUR_DESIRED_LIMIT)) so backpressure
prevents unbounded memory growth, and make the limit configurable (via a
constant or config value) so it can be tuned; update any producer code that
calls audio_q.put_nowait() to use await audio_q.put() (or handle QueueFull) and
keep references like audio_q, audio_receiver, and recv_task in place.
- Around line 135-144: The JSON parsing of control frames using json.loads in
audio_receiver can raise and cause the function to exit without placing the None
sentinel into audio_q, deadlocking stt.stream_events; wrap the json.loads(...)
call (and any immediate processing of the control frame) in a try/except (catch
json.JSONDecodeError or Exception), and in the except ensure you always await
audio_q.put(None) to unblock the consumer and then re-raise or propagate the
error so the failure is visible to the caller; make this change in the
audio_receiver code path that reads msg["text"] and handles msg_type == "stop".

In `@app/core/DB/PDF2db_v2.py`:
- Around line 351-360: The code prints sensitive summary content and section
titles (variables raw_content, raw, section_title) for every chunk; change this
so only non-sensitive metadata (like finish_reason and raw_content length) is
always logged, and the actual summary text or section title is only emitted when
an explicit debug flag is enabled (e.g., a module-level DEBUG_SUMMARY_LOGGING or
logger.isEnabledFor(logging.DEBUG)). Update the print calls in the block that
reads raw_content/finish_reason to use structured logging of
length/finish_reason and replace raw/section_title prints with conditional
debug-only logs tied to that flag or debug logger level (ensure raw_content is
never printed in normal ops). Also keep the existing None check and safe
fallback but avoid printing raw snippets except under the debug guard.
- Around line 137-158: The branch that returns early when there are no header
matches skips the common split/merge logic and causes very long chunks; instead
of returning immediately in the if not matches block, create a synthetic section
(e.g., one or multiple sections from paragraphs with "section_title" like "본문"
or "본문 {i+1}") and add them to the same sections list used by the downstream
splitting/merging code (the logic that handles max_chunk_size and merging short
chunks), then let the function continue into the existing split/merge pipeline
(i.e., do not return from inside the no-matches branch); locate the code around
md_text/matches handling in PDF2db_v2.py and replace the early returns with code
that constructs synthetic section dicts and appends them so the rest of the
function (split/merge routines) runs normally.

In `@app/core/services/document_processor.py`:
- Around line 165-172: The code reads the entire HTTP response into memory via
resp.content before enforcing MAX_PDF_BYTES, which can blow memory and
bandwidth; change the download to stream the response (use
httpx.AsyncClient.get(..., stream=True) and iterate
resp.aiter_bytes()/aiter_raw()) accumulate bytes into pdf_bytes (or a BytesIO)
while tracking total_size, and if total_size exceeds MAX_PDF_BYTES immediately
close/aiterator/raise ValueError to abort the download; ensure you still call
resp.aclose() or use "async with" for the streamed response and keep the rest of
the logic (PDF size check and subsequent use of pdf_bytes) intact.
- Around line 99-104: The embedding input in document_processor.py builds
search_text with f"{section_title} {summary} {' '.join(keywords)}" which is the
old format and causes inconsistent vectors; replace that inline construction by
calling the existing helper build_search_text(section_title, summary, keywords)
so the tb_doc_chunk_v2/pipeline_version="v2" path uses the same
"[section]/[summary]/[keywords]" format as PDF2db_v2.py; update the code that
calls openai_client_v2.embeddings.create(...) to pass the build_search_text
result and remove the manual join to ensure all v2 pipelines use identical input
formatting.

In `@app/core/ws_stt_test.py`:
- Around line 118-122: The prints compute latencies by subtracting timestamps
but can raise TypeError when the other operand is None; update the checks to
ensure both timestamps exist before computing/subtracting: for the first latency
verify both t_llm_first and t_stt_final are not None before printing, and for
the second verify both t_tts_first and t_llm_first are not None before printing
(references: t_llm_first, t_stt_final, t_tts_first).

In `@app/graph/nodes/answer/event_node.py`:
- Around line 150-157: The code passes a naive datetime string to
_build_event_messages (now_str=datetime.now().strftime(...)) which can cause
day-boundary errors across timezones; change to supply a timezone-aware
timestamp and prefer using the site/site-config timezone injected into this node
(fallback to UTC). Modify the call that builds messages in event_node.py to
obtain the timezone-aware now via the node/state/config (e.g., site_tz) and
format it with an offset (ISO 8601 or include %z) when setting now_str; if no
site timezone is available, use utcnow() with UTC tzinfo so
_build_event_messages always receives an offset-aware timestamp. Ensure the
unique identifiers _build_event_messages and now_str are updated accordingly.

In `@app/graph/nodes/answer/struct_db_node.py`:
- Line 98: The code assigns persona_block using an unnecessary f-string:
persona_block = "\n".join(persona_lines) if persona_lines else f"You are a
friendly tourist guide assistant."; change the else branch to a plain string
literal (remove the leading f) so the fallback becomes "You are a friendly
tourist guide assistant." to eliminate the unused f-string. Ensure this change
is applied where persona_block and persona_lines are used in struct_db_node.py.

In `@app/graph/nodes/classify/intent_gate.py`:
- Around line 69-78: json.loads(raw) can return non-dict values (list/str) and
the subsequent parsed.get(...) will raise AttributeError; update the logic after
llm.chat(...) to validate that parsed is a dict and if not set ranking =
_DEFAULT_RANKING and place_category = None before attempting parsed.get; use the
same symbols (llm.chat, parsed, ranking, place_category, _DEFAULT_RANKING,
_ALLOWED_PLACE_CATEGORIES) so you locate the block, then only call parsed.get
when isinstance(parsed, dict) and otherwise fall back safely.

In `@audio/m4a.py`:
- Around line 27-33: The subprocess.run call that executes ffmpeg (the call
using cmd and storing result in result) must include a timeout and explicit
exception handling: wrap the call in a try/except that catches
subprocess.TimeoutExpired and FileNotFoundError (and optionally other
Exceptions), use a sensible timeout constant (e.g., FFMPEG_TIMEOUT) when calling
subprocess.run(..., timeout=FFMPEG_TIMEOUT, capture_output=True, text=True), and
on TimeoutExpired and FileNotFoundError log distinct, clear messages referencing
the filename (and the expected FFMPEG_EXE) instead of treating them the same as
a non-zero return code; preserve the existing success branch that prints saved
output_path and keep the non-zero returncode handling for ffmpeg failures.
- Around line 1-7: The constant FFMPEG_EXE in audio/m4a.py is a hardcoded,
user-specific absolute path; remove that literal and instead resolve ffmpeg at
runtime by first checking an environment variable (e.g. FFMPEG_EXE or
FFMPEG_PATH) and then falling back to locating "ffmpeg" on PATH (shutil.which or
equivalent) or a sensible default command string; update any code that
references FFMPEG_EXE to use the resolved value and raise a clear error if
ffmpeg cannot be found.
- Around line 8-35: The module runs the conversion loop on import, causing side
effects; wrap the existing top-level logic (the os.makedirs call, the for
filename in os.listdir(INPUT_DIR) loop, subprocess.run invocation and related
variables like INPUT_DIR, OUTPUT_DIR, FFMPEG_EXE) into a function named main()
and add an execution guard if __name__ == "__main__": main() so the conversion
only runs when executed as a script and not when imported.

---

Nitpick comments:
In `@app/core/dependencies.py`:
- Line 18: The code instantiates client =
OpenAI(api_key=os.environ["OPENAI_API_KEY"]) which raises a generic KeyError on
import; change to read the key via os.getenv (e.g., api_key =
os.getenv("OPENAI_API_KEY")), validate that api_key is present and if not raise
a clear ValueError("OPENAI_API_KEY environment variable is required"), then pass
that api_key into OpenAI(...) to create client; update references to the client
variable as needed.

In `@app/core/services/llm_openai.py`:
- Line 72: 함수 chat의 시그니처에서 암묵적 Optional인 `max_tokens: int = None`을 명시적으로 바꿔 PEP
484 규약을 따르세요: `max_tokens: Optional[int] = None`로 변경하고 파일 상단(또는 타입 임포트 위치)에
`from typing import Optional`를 추가하여 타입이 정의되도록 하세요; 대상 심볼은 메서드 이름 `chat`을 수정하면
됩니다.
- Around line 20-23: The current sentence-splitting regex _SENTENCE_RE can split
on decimal points (e.g., "123.45입니다.") because it treats any period as a
sentence terminator; update the pattern used by _SENTENCE_RE to avoid matching
periods that are part of numeric decimals by adding a negative lookbehind for
digits (so a period is only considered a terminator when not preceded by a
digit) and/or require whitespace or end-of-string after the terminator for
Korean endings like "다.", "요.", "니다."; modify the _SENTENCE_RE definition
accordingly to use a negative lookbehind for digits and to validate what follows
the punctuation to prevent splitting inside numbers.

In `@app/core/services/tts_google.py`:
- Around line 35-36: The fallback for empty text is hardcoded to Korean; update
the empty-text handling in the function that sets `text` (in
app/core/services/tts_google.py) to use `language_code` instead of a fixed
Korean string: either map common language codes to localized fallback messages
based on `language_code`, or raise/return an error so the caller handles empty
input; ensure the branch that currently does `if not text or not text.strip():
text = "음성으로 변환할 내용이 없습니다."` uses the `language_code` variable to select the
appropriate message or propagates an error to the caller.

In `@app/graph/nodes/answer/struct_db_node.py`:
- Around line 34-36: When nearby_places is empty, the function returns without
including required GraphState fields emotion and category; update the
early-return block in struct_db_node.py (the branch that sets trace["struct_db"]
= {"status": "no_context", "query": text} and returns {"answer_text": "",
"check_result": "bad", "trace": trace}) to also include default values for
emotion and category (e.g., empty strings or defined defaults) so the returned
state shape matches the successful branch and GraphState's required fields.
- Around line 159-161: The llm_failed branch omits default emotion and category
values; update the branch in the function that sets trace["struct_db"] (the
block returning {"answer_text": "", "check_result": "bad", "trace": trace}) to
include default keys for emotion and category both in trace["struct_db"] (e.g.,
"emotion": "", "category": "") and in the returned payload so the response shape
matches success cases; ensure you modify the same block that sets "status":
"llm_failed" and uses variables method and error_msg.

In `@app/graph/nodes/tool/fetch_places_node.py`:
- Around line 41-45: The synchronous requests.get call (resp =
requests.get(...)) blocks the event loop in async contexts; update the
fetch_places_node function to perform the HTTP request off the event loop (e.g.,
wrap the blocking call in asyncio.to_thread when using requests) or convert the
node to use an async HTTP client (e.g., httpx.AsyncClient or
aiohttp.ClientSession) and replace requests.get with an awaitable async call to
avoid blocking; ensure you keep the same params and timeout handling and return
shape so callers of fetch_places_node (or the resp handling logic) continue to
work.
- Around line 68-71: The except block in fetch_places (the except Exception as e
handler) only prints the exception type and sets trace to "core_places_failed";
replace the simple print with structured logging that includes the exception
message and stack trace (e.g., use logger.exception or logger.error(...,
exc_info=True)) and augment the trace entry (trace["fetch_places"]) to include
the actual error message (and optionally a short stack/identifier) so debugging
external service failures is easier; update references in the function/method
handling fetch_places to use the module logger rather than print and keep
returning the same result shape.

In `@audio/m4a.py`:
- Around line 10-12: The loop over os.listdir(INPUT_DIR) currently only checks
filename.lower().endswith(".m4a") which can match directories named *.m4a;
update the condition to also verify the path is a regular file by computing
input_path = os.path.join(INPUT_DIR, filename) and adding
os.path.isfile(input_path) to the conditional so only real files (not
directories) are processed; ensure you reference INPUT_DIR, filename and
input_path when making the change.

In `@record.py`:
- Line 6: The FILENAME constant is hardcoded ("궁성담장길이.wav") which can break
consumers expecting different paths (e.g., WAV_PATH in app/core/ws_stt_test.py);
change FILENAME to be configurable by reading from an environment variable, a
configuration object, or accepting it as a function/class parameter and provide
a sensible default, and update any callers to pass the expected path (or read
the same config) so both record.py's FILENAME and ws_stt_test.py's WAV_PATH come
from the same configurable source.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: abf45233-3225-4238-9495-cd26adc93165

📥 Commits

Reviewing files that changed from the base of the PR and between 485e56c and 9ddc078.

⛔ Files ignored due to path filters (2)
  • input.wav is excluded by !**/*.wav
  • out.mp3 is excluded by !**/*.mp3
📒 Files selected for processing (56)
  • .coderabbit.yaml
  • .gitignore
  • app/api/__init__.py
  • app/api/document.py
  • app/api/document_legacy.py
  • app/api/health.py
  • app/api/qa.py
  • app/api/websocket.py
  • app/core/DB/PDF2db_v2.py
  • app/core/dependencies.py
  • app/core/main_fastapi.py
  • app/core/services/document_processor.py
  • app/core/services/llm_openai.py
  • app/core/services/pipeline.py
  • app/core/services/stt_google.py
  • app/core/services/tts_google.py
  • app/core/ws_stt_test.py
  • app/graph/graph_builder.py
  • app/graph/nodes/answer/__init__.py
  • app/graph/nodes/answer/answer_check.py
  • app/graph/nodes/answer/answer_generate.py
  • app/graph/nodes/answer/clarify_node.py
  • app/graph/nodes/answer/event_node.py
  • app/graph/nodes/answer/smalltalk_node.py
  • app/graph/nodes/answer/struct_db_node.py
  • app/graph/nodes/answer_compose.py
  • app/graph/nodes/classify/__init__.py
  • app/graph/nodes/classify/intent_gate.py
  • app/graph/nodes/direct_llm_node.py
  • app/graph/nodes/infotype_gate.py
  • app/graph/nodes/input/__init__.py
  • app/graph/nodes/input/normalize_node.py
  • app/graph/nodes/input/stt_node.py
  • app/graph/nodes/intent_gate.py
  • app/graph/nodes/map_tool_node.py
  • app/graph/nodes/output/__init__.py
  • app/graph/nodes/output/tts_builder.py
  • app/graph/nodes/query_rewrite.py
  • app/graph/nodes/rag/__init__.py
  • app/graph/nodes/rag/context_pack.py
  • app/graph/nodes/rag/retrieve_node.py
  • app/graph/nodes/rag/retrieve_node_v2.py
  • app/graph/nodes/rag/translate_node.py
  • app/graph/nodes/smalltalk_node.py
  • app/graph/nodes/struct_db_node.py
  • app/graph/nodes/tool/__init__.py
  • app/graph/nodes/tool/fetch_places_node.py
  • app/graph/routers/answer_check_router.py
  • app/graph/routers/fallback_router.py
  • app/graph/routers/infotype_router.py
  • app/graph/routers/intent_router.py
  • app/graph/state.py
  • app/main.py
  • audio/m4a.py
  • record.py
  • seed_gyeongbokgung_test.sql
💤 Files with no reviewable changes (10)
  • app/graph/nodes/struct_db_node.py
  • app/graph/nodes/map_tool_node.py
  • app/graph/routers/infotype_router.py
  • app/graph/nodes/answer_compose.py
  • app/graph/nodes/direct_llm_node.py
  • app/graph/nodes/query_rewrite.py
  • app/graph/nodes/intent_gate.py
  • app/graph/nodes/smalltalk_node.py
  • app/graph/nodes/infotype_gate.py
  • app/core/main_fastapi.py

Comment thread .gitignore
Comment on lines +18 to +19
google-credentials.json
*.json

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

*.json 패턴이 너무 광범위합니다.

모든 JSON 파일을 무시하면 다음과 같은 중요한 파일들이 실수로 추적에서 제외될 수 있습니다:

  • package.json, tsconfig.json 등 설정 파일
  • API 스키마 정의 파일
  • 테스트 fixture 데이터

자격 증명 파일만 명시적으로 나열하는 것이 더 안전합니다.

🔧 권장 수정안
 # credentials & environment
 google_key.json
 google-credentials.json
-*.json
+service-account*.json
+credentials*.json
 .env
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.gitignore around lines 18 - 19, The .gitignore currently contains a broad
"*.json" rule which will exclude important config and schema files; remove the
"*.json" entry and instead explicitly ignore only credential files like
"google-credentials.json" (and any other secrets you have), or if you must keep
a wildcard, add explicit allow-lists for important files such as "package.json",
"tsconfig.json", and your API/schema/test fixture files (e.g., using negation
patterns like "!package.json" or listing "schemas/*.json") so that only
sensitive JSONs are ignored and config/artifact JSONs remain tracked.

Comment thread app/api/document_legacy.py
Comment thread app/api/document.py
Comment thread app/api/qa.py
Comment thread app/api/websocket.py
f"- CRITICAL: The style MUST be visible in your response.\n"
f"- CRITICAL: Do NOT use the original Korean words — always translate them into {lang_name}."
)
persona_block = "\n".join(persona_lines) if persona_lines else f"You are a friendly tourist guide assistant."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

불필요한 f-string 접두사를 제거하세요.

플레이스홀더가 없는 f-string입니다.

🔧 수정 제안
-            persona_block = "\n".join(persona_lines) if persona_lines else f"You are a friendly tourist guide assistant."
+            persona_block = "\n".join(persona_lines) if persona_lines else "You are a friendly tourist guide assistant."
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
persona_block = "\n".join(persona_lines) if persona_lines else f"You are a friendly tourist guide assistant."
persona_block = "\n".join(persona_lines) if persona_lines else "You are a friendly tourist guide assistant."
🧰 Tools
🪛 Ruff (0.15.9)

[error] 98-98: f-string without any placeholders

Remove extraneous f prefix

(F541)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/graph/nodes/answer/struct_db_node.py` at line 98, The code assigns
persona_block using an unnecessary f-string: persona_block =
"\n".join(persona_lines) if persona_lines else f"You are a friendly tourist
guide assistant."; change the else branch to a plain string literal (remove the
leading f) so the fallback becomes "You are a friendly tourist guide assistant."
to eliminate the unused f-string. Ensure this change is applied where
persona_block and persona_lines are used in struct_db_node.py.

Comment on lines +69 to +78
raw = llm.chat(messages, max_tokens=60)
parsed = json.loads(raw)
ranking = parsed.get("ranking", _DEFAULT_RANKING)
# 1순위가 struct_db일 때만 place_category 적용, 허용 enum 외 값은 None
raw_category = parsed.get("place_category")
place_category = (
raw_category.upper()
if isinstance(raw_category, str) and raw_category.upper() in _ALLOWED_PLACE_CATEGORIES
else None
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

유효한 JSON이어도 객체가 아니면 여기서 바로 실패합니다.

Line 70의 json.loads()는 리스트나 문자열도 정상 반환할 수 있는데, Line 71에서 바로 parsed.get(...)를 호출하고 있습니다. 모델이 ["rag", ...]처럼 shape만 어긋난 JSON을 내놓으면 AttributeError로 노드 전체가 깨지고, 아래 fallback 경로도 타지 못합니다. dict 여부를 먼저 검증하고 아니면 _DEFAULT_RANKING으로 강등하는 편이 안전합니다.

💡 예시 수정안
         try:
             raw = llm.chat(messages, max_tokens=60)
             parsed = json.loads(raw)
+            if not isinstance(parsed, dict):
+                parsed = {}
+                method = "llm_fallback_invalid_shape"
             ranking = parsed.get("ranking", _DEFAULT_RANKING)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/graph/nodes/classify/intent_gate.py` around lines 69 - 78,
json.loads(raw) can return non-dict values (list/str) and the subsequent
parsed.get(...) will raise AttributeError; update the logic after llm.chat(...)
to validate that parsed is a dict and if not set ranking = _DEFAULT_RANKING and
place_category = None before attempting parsed.get; use the same symbols
(llm.chat, parsed, ranking, place_category, _DEFAULT_RANKING,
_ALLOWED_PLACE_CATEGORIES) so you locate the block, then only call parsed.get
when isinstance(parsed, dict) and otherwise fall back safely.

Comment thread audio/m4a.py
Comment on lines +1 to +7
import os
import subprocess

INPUT_DIR = "."
OUTPUT_DIR = "wav"
FFMPEG_EXE = r"C:\Users\hjsju\AppData\Local\Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-8.1-full_build\bin\ffmpeg.exe"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "m4a.py" -type f

Repository: Project-Guideon/guideon-AI

Length of output: 84


🏁 Script executed:

cat -n ./audio/m4a.py

Repository: Project-Guideon/guideon-AI

Length of output: 1323


Line 6의 하드코딩된 로컬 ffmpeg 절대 경로를 제거하세요.

C:\Users\hjsju\AppData\Local\...는 특정 PC에서만 동작하며, 사용자 이름이 노출됩니다. CI 환경과 다른 개발 머신에서 즉시 실패합니다.

대신 환경 변수나 PATH에서 ffmpeg를 찾도록 변경하세요:

제안 패치
 import os
+import shutil
 import subprocess
 
 INPUT_DIR = "."
 OUTPUT_DIR = "wav"
-FFMPEG_EXE = r"C:\Users\hjsju\AppData\Local\Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-8.1-full_build\bin\ffmpeg.exe"
+FFMPEG_EXE = os.getenv("FFMPEG_EXE") or shutil.which("ffmpeg")
+if not FFMPEG_EXE:
+    raise RuntimeError("ffmpeg 실행 파일을 찾지 못했습니다. PATH 또는 FFMPEG_EXE를 설정하세요.")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import os
import subprocess
INPUT_DIR = "."
OUTPUT_DIR = "wav"
FFMPEG_EXE = r"C:\Users\hjsju\AppData\Local\Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-8.1-full_build\bin\ffmpeg.exe"
import os
import shutil
import subprocess
INPUT_DIR = "."
OUTPUT_DIR = "wav"
FFMPEG_EXE = os.getenv("FFMPEG_EXE") or shutil.which("ffmpeg")
if not FFMPEG_EXE:
raise RuntimeError("ffmpeg 실행 파일을 찾지 못했습니다. PATH 또는 FFMPEG_EXE를 설정하세요.")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@audio/m4a.py` around lines 1 - 7, The constant FFMPEG_EXE in audio/m4a.py is
a hardcoded, user-specific absolute path; remove that literal and instead
resolve ffmpeg at runtime by first checking an environment variable (e.g.
FFMPEG_EXE or FFMPEG_PATH) and then falling back to locating "ffmpeg" on PATH
(shutil.which or equivalent) or a sensible default command string; update any
code that references FFMPEG_EXE to use the resolved value and raise a clear
error if ffmpeg cannot be found.

Comment thread audio/m4a.py
Comment on lines +8 to +35
os.makedirs(OUTPUT_DIR, exist_ok=True)

for filename in os.listdir(INPUT_DIR):
if filename.lower().endswith(".m4a"):
input_path = os.path.join(INPUT_DIR, filename)
output_name = os.path.splitext(filename)[0] + ".wav"
output_path = os.path.join(OUTPUT_DIR, output_name)

print(f"Converting: {filename} → {output_name}")

cmd = [
FFMPEG_EXE,
"-y",
"-i", input_path,
"-ac", "1",
"-ar", "16000",
output_path
]

result = subprocess.run(cmd, capture_output=True, text=True)

if result.returncode != 0:
print(f"❌ failed: {filename}")
print(result.stderr)
else:
print(f"✅ saved: {output_path}")

print("done") No newline at end of file

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "m4a.py" -type f

Repository: Project-Guideon/guideon-AI

Length of output: 84


🏁 Script executed:

cat -n ./audio/m4a.py

Repository: Project-Guideon/guideon-AI

Length of output: 1323


🏁 Script executed:

rg "from audio import|import audio\.m4a|import audio" --type py

Repository: Project-Guideon/guideon-AI

Length of output: 52


🏁 Script executed:

rg "m4a" --type py

Repository: Project-Guideon/guideon-AI

Length of output: 124


모듈 import 시 자동 실행되는 부작용을 분리해주세요.

현재 audio/m4a.py를 import하는 순간 파일 변환 루프가 실행됩니다. 코드 블록 전체를 main() 함수로 감싼 후 if __name__ == "__main__": 가드를 추가하여 직접 실행할 때만 동작하도록 변경해야 합니다.

제안 패치
-os.makedirs(OUTPUT_DIR, exist_ok=True)
-
-for filename in os.listdir(INPUT_DIR):
-    if filename.lower().endswith(".m4a"):
-        input_path = os.path.join(INPUT_DIR, filename)
-        output_name = os.path.splitext(filename)[0] + ".wav"
-        output_path = os.path.join(OUTPUT_DIR, output_name)
-
-        print(f"Converting: {filename} → {output_name}")
-
-        cmd = [
-            FFMPEG_EXE,
-            "-y",
-            "-i", input_path,
-            "-ac", "1",
-            "-ar", "16000",
-            output_path
-        ]
-
-        result = subprocess.run(cmd, capture_output=True, text=True)
-
-        if result.returncode != 0:
-            print(f"❌ failed: {filename}")
-            print(result.stderr)
-        else:
-            print(f"✅ saved: {output_path}")
-
-print("done")
+def main() -> None:
+    os.makedirs(OUTPUT_DIR, exist_ok=True)
+    for filename in os.listdir(INPUT_DIR):
+        if filename.lower().endswith(".m4a"):
+            input_path = os.path.join(INPUT_DIR, filename)
+            output_name = os.path.splitext(filename)[0] + ".wav"
+            output_path = os.path.join(OUTPUT_DIR, output_name)
+            print(f"Converting: {filename} → {output_name}")
+            # ... existing conversion logic ...
+    print("done")
+
+if __name__ == "__main__":
+    main()
🧰 Tools
🪛 Ruff (0.15.9)

[error] 27-27: subprocess call: check for execution of untrusted input

(S603)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@audio/m4a.py` around lines 8 - 35, The module runs the conversion loop on
import, causing side effects; wrap the existing top-level logic (the os.makedirs
call, the for filename in os.listdir(INPUT_DIR) loop, subprocess.run invocation
and related variables like INPUT_DIR, OUTPUT_DIR, FFMPEG_EXE) into a function
named main() and add an execution guard if __name__ == "__main__": main() so the
conversion only runs when executed as a script and not when imported.

Comment thread audio/m4a.py
Comment on lines +27 to +33
result = subprocess.run(cmd, capture_output=True, text=True)

if result.returncode != 0:
print(f"❌ failed: {filename}")
print(result.stderr)
else:
print(f"✅ saved: {output_path}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "m4a.py" | head -20

Repository: Project-Guideon/guideon-AI

Length of output: 84


🏁 Script executed:

cat -n ./audio/m4a.py

Repository: Project-Guideon/guideon-AI

Length of output: 1323


subprocess.run() 호출에 timeout과 예외 처리를 추가하세요.

Line 27의 subprocess.run()은 timeout 없이 ffmpeg가 무한 대기할 수 있으며, FFMPEG_EXE 경로가 존재하지 않으면 FileNotFoundError가 발생합니다. 배치 변환 스크립트에서는 실패 원인을 명시적으로 분리하여 처리하는 것이 필수입니다.

제안 패치
-        result = subprocess.run(cmd, capture_output=True, text=True)
-
-        if result.returncode != 0:
-            print(f"❌ failed: {filename}")
-            print(result.stderr)
-        else:
-            print(f"✅ saved: {output_path}")
+        try:
+            result = subprocess.run(
+                cmd,
+                capture_output=True,
+                text=True,
+                timeout=120,
+                check=False,
+            )
+        except FileNotFoundError:
+            print("❌ failed: ffmpeg 실행 파일을 찾지 못했습니다.")
+            break
+        except subprocess.TimeoutExpired:
+            print(f"❌ failed: {filename} (timeout)")
+            continue
+
+        if result.returncode != 0:
+            print(f"❌ failed: {filename}")
+            print(result.stderr)
+        else:
+            print(f"✅ saved: {output_path}")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"❌ failed: {filename}")
print(result.stderr)
else:
print(f"✅ saved: {output_path}")
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120,
check=False,
)
except FileNotFoundError:
print("❌ failed: ffmpeg 실행 파일을 찾지 못했습니다.")
break
except subprocess.TimeoutExpired:
print(f"❌ failed: {filename} (timeout)")
continue
if result.returncode != 0:
print(f"❌ failed: {filename}")
print(result.stderr)
else:
print(f"✅ saved: {output_path}")
🧰 Tools
🪛 Ruff (0.15.9)

[error] 27-27: subprocess call: check for execution of untrusted input

(S603)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@audio/m4a.py` around lines 27 - 33, The subprocess.run call that executes
ffmpeg (the call using cmd and storing result in result) must include a timeout
and explicit exception handling: wrap the call in a try/except that catches
subprocess.TimeoutExpired and FileNotFoundError (and optionally other
Exceptions), use a sensible timeout constant (e.g., FFMPEG_TIMEOUT) when calling
subprocess.run(..., timeout=FFMPEG_TIMEOUT, capture_output=True, text=True), and
on TimeoutExpired and FileNotFoundError log distinct, clear messages referencing
the filename (and the expected FFMPEG_EXE) instead of treating them the same as
a non-zero return code; preserve the existing success branch that prints saved
output_path and keep the non-zero returncode handling for ffmpeg failures.

- 스프링 bff에서 이미 검증하고 있으므로 중복 검증되어 삭제하였습니다.
-PDFdb_v2에 맞춰서 build_search_text 추가했습니다.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/core/services/document_processor.py`:
- Around line 191-193: The exception message passed to _callback_core as str(e)
may leak sensitive data (signed URL tokens, API keys, DB details); sanitize the
exception before sending by reusing the same URL redaction logic used elsewhere
(urlsplit/urlunsplit) and stripping or redacting known sensitive patterns (query
strings, Authorization/API keys, long JSON bodies) so only a safe summary is
stored; update the try/except handler that currently calls await
_callback_core(site_id, doc_id, "FAILED", str(e)) to build a redacted_message
(e.g., remove query params from any URLs in the exception, truncate long texts,
and mask token-like substrings) and pass that redacted_message to _callback_core
instead of raw str(e).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: df17d0a0-beaf-4f5c-8f8d-cb891d096f13

📥 Commits

Reviewing files that changed from the base of the PR and between 9ddc078 and 43c3d7d.

📒 Files selected for processing (1)
  • app/core/services/document_processor.py

Comment thread app/core/services/document_processor.py
Comment thread app/core/services/document_processor.py Outdated
- 재시도 로직:콜백 실패 시 0.5초, 1초 간격으로 최대 3회 재시도
- 에러 메시지 정제 : URL에 토큰/쿼리 파라미터 포함된 경우 제거 후 500자 제한

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
app/core/services/document_processor.py (1)

195-198: ⚠️ Potential issue | 🟠 Major

_sanitize_failed_reason()가 “문장 내부 URL”의 query/token을 제거하지 못합니다.

Line 196-197 로직은 reason 문자열 전체가 URL일 때만 정제됩니다. 예외 메시지에 URL이 일부로 포함되면 query가 그대로 남을 수 있습니다.

🔧 제안 수정안
+import re
+
+_URL_RE = re.compile(r"https?://[^\s)]+")
+
 def _sanitize_failed_reason(reason: str, max_len: int = 500) -> str:
-    p = urlsplit(reason)
-    scrubbed = urlunsplit((p.scheme, p.netloc, p.path, "", "")) if p.scheme and p.netloc else reason
+    def _strip_query(m: re.Match[str]) -> str:
+        u = m.group(0)
+        p = urlsplit(u)
+        return urlunsplit((p.scheme, p.netloc, p.path, "", ""))
+
+    scrubbed = _URL_RE.sub(_strip_query, reason)
     return scrubbed[:max_len]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/core/services/document_processor.py` around lines 195 - 198, The
_sanitize_failed_reason function only strips query/fragments when the whole
reason string parses as a URL; update it to detect and sanitize URLs embedded
inside a larger sentence by finding URL substrings (e.g., via a URL regex),
parsing each match with urlsplit and replacing the matched substring with
urlunsplit((scheme, netloc, path, "", "")) so query/fragment/token parts are
removed, then return the scrubbed result truncated to max_len; target the
_sanitize_failed_reason function and its local variables (p, scrubbed) when
applying this change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/core/services/document_processor.py`:
- Around line 83-87: Update the inaccurate comment around
rows_to_insert/generate_search_summary to match the real summarization model
used (not “GPT-4o”); open the code that defines the v2 summary model (referenced
as MODEL_NAME in PDF2db_v2.py) and replace “GPT-4o” in the comment with the
actual model identifier and any pertinent behavior (e.g., v2 summary model name,
temperature/settings, keyword extraction approach) so the comment accurately
reflects generate_search_summary and the embedding input composition ("section
title + summary + keywords").

---

Duplicate comments:
In `@app/core/services/document_processor.py`:
- Around line 195-198: The _sanitize_failed_reason function only strips
query/fragments when the whole reason string parses as a URL; update it to
detect and sanitize URLs embedded inside a larger sentence by finding URL
substrings (e.g., via a URL regex), parsing each match with urlsplit and
replacing the matched substring with urlunsplit((scheme, netloc, path, "", ""))
so query/fragment/token parts are removed, then return the scrubbed result
truncated to max_len; target the _sanitize_failed_reason function and its local
variables (p, scrubbed) when applying this change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9cd6b93e-ae68-41f6-b98a-bfb7e1589f4a

📥 Commits

Reviewing files that changed from the base of the PR and between 43c3d7d and 60e1294.

📒 Files selected for processing (1)
  • app/core/services/document_processor.py

Comment on lines +83 to +87
# - generate_search_summary: GPT-4o로 섹션 요약 + 키워드 추출
# - 임베딩 대상: "섹션제목 + 요약 + 키워드" 합성 텍스트
# (원문 대신 요약 텍스트를 임베딩하여 검색 정확도 향상)
# - 임베딩 모델: MODEL_NAME (PDF2db_v2.py에서 정의)
rows_to_insert = []

@coderabbitai coderabbitai Bot Apr 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

주석의 모델 설명이 현재 구현과 불일치할 수 있습니다.

Line 83의 “GPT-4o” 설명은 실제 v2 요약 모델 설정과 다를 수 있어 운영/장애 분석 시 혼선을 줄 수 있습니다. 주석을 실제 모델 소스 기준으로 맞춰 주세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/core/services/document_processor.py` around lines 83 - 87, Update the
inaccurate comment around rows_to_insert/generate_search_summary to match the
real summarization model used (not “GPT-4o”); open the code that defines the v2
summary model (referenced as MODEL_NAME in PDF2db_v2.py) and replace “GPT-4o” in
the comment with the actual model identifier and any pertinent behavior (e.g.,
v2 summary model name, temperature/settings, keyword extraction approach) so the
comment accurately reflects generate_search_summary and the embedding input
composition ("section title + summary + keywords").

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추후 모델 개발 완성 후 추가하겠습니다. 이슈 부탁드립니다.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

이슈를 생성하겠습니다.


🏁 Script executed:

gh issue create \
  --repo Project-Guideon/guideon-AI \
  --title "[document_processor] 주석의 모델 설명을 실제 요약 모델로 업데이트" \
  --body "## 개요

\`app/core/services/document_processor.py\`의 Line 83 주석에 \`GPT-4o\`로 명시되어 있으나, 실제 v2 요약 모델(\`PDF2db_v2.py\`의 \`MODEL_NAME\` 기준)과 불일치할 수 있습니다. 운영 및 장애 분석 시 혼선을 줄이기 위해, 모델 개발 완료 후 주석을 실제 사용 모델 식별자 및 관련 설정(temperature, 키워드 추출 방식 등)에 맞게 수정이 필요합니다.

## 수정 대상

- 파일: \`app/core/services/document_processor.py\`
- 위치: Line 83 ~ 87 (\`generate_search_summary\` 관련 주석)
- 현재 내용: \"GPT-4o로 섹션 요약 + 키워드 추출\" 등 모델명 하드코딩
- 수정 방향: \`PDF2db_v2.py\`의 \`MODEL_NAME\` 또는 실제 요약 모델 식별자로 교체하고, 해당 모델의 주요 설정 및 동작을 반영

## 관련 링크

- PR: https://github.com/Project-Guideon/guideon-AI/pull/77
- 리뷰 코멘트: https://github.com/Project-Guideon/guideon-AI/pull/77#discussion_r3044762881
- 요청자: `@hwangtae123`" \
  --assignee hwangtae123

Length of output: 55


@hwangtae123, 이슈를 생성했습니다: #80 - [document_processor] 주석의 모델 설명을 실제 요약 모델로 업데이트

모델 개발 완료 후 app/core/services/document_processor.py Line 83의 주석을 실제 MODEL_NAME 기준으로 수정해 주시면 됩니다. 감사합니다! 🙏


🧠 Learnings used
Learnt from: hwangtae123
Repo: Project-Guideon/guideon-AI PR: 44
File: app/api/document.py:48-53
Timestamp: 2026-04-01T17:28:20.980Z
Learning: In the guideon-AI project, document processing idempotency is managed entirely by the Spring Boot Core service. Core enforces a PENDING → PROCESSING → COMPLETED state machine with a unique constraint in the DB, so the FastAPI endpoint at app/api/document.py (POST /v1/documents/process) does not need an additional in-progress guard or distributed lock. Duplicate requests are blocked on the Core side before they reach the AI processing pipeline.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants