From 52dd589f8c6a52a717ae729d40f496726559629c Mon Sep 17 00:00:00 2001 From: code-agent Date: Sat, 1 Jan 2000 00:00:00 +0000 Subject: [PATCH] code-agent: implement issue 4 Approval-Correlation: 0543bb321a84cb936bc0ac4988615840 Plan-SHA256: 437a85f68d151ed4db780511812834041cfec5e7cf021fb77e238d5a6196e6fb Base-SHA: 256be64ac71a0bdc6df93131b9f4865e0850f487 --- README.md | 4 +- docs/data-model.md | 40 ++++++++-- docs/interface-contract.md | 42 ++++++---- docs/project-structure.md | 49 +++++++----- docs/requirements/req-001_project-scaffold.md | 34 ++++++-- docs/runtime-flow.md | 46 ++++++++--- src/chat_tui/app.py | 27 +++++-- tests/test_app.py | 79 +++++++++++++++---- 8 files changed, 238 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index ceda212..3016bab 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # gitea-agent-testbed -Python 3.11 이상에서 실행하는 Textual 기반 대화형 TUI 스캐폴드입니다. 입력한 문자열을 세션 내 대화 영역에 누적 표시하며, LLM이나 외부 API는 호출하지 않습니다. +Python 3.11 이상에서 실행하는 Textual 기반 대화형 TUI 스캐폴드입니다. 입력한 문자열을 `trim`한 뒤 세션 내 대화 영역에 누적 표시하고, 유효한 메시지마다 고정된 다섯 개 응답 중 하나를 표준 난수로 선택해 바로 뒤에 표시합니다. LLM이나 외부 API는 호출하지 않습니다. ## 설치 @@ -16,7 +16,7 @@ chat-tui python -m chat_tui ``` -입력창에서 메시지를 입력하고 Enter를 누르면 대화 영역에 표시됩니다. `Escape` 또는 `Ctrl+Q`로 종료합니다. +입력창에서 메시지를 입력하고 Enter를 누르면 사용자 메시지와 무작위 응답이 대화 영역에 표시됩니다. 공백만 입력한 경우에는 아무 대화 항목도 추가되지 않습니다. `Escape` 또는 `Ctrl+Q`로 종료합니다. ## 테스트 diff --git a/docs/data-model.md b/docs/data-model.md index 5ee7a46..82174e4 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -1,13 +1,39 @@ # 데이터 모델 -## input-display-state +## 대화 상태 -초기 구현의 상태는 `ChatApp.submitted_inputs`와 화면 위젯으로 구성한다. +`ChatApp`은 세션 동안 다음 목록을 메모리에 유지한다. -- `Input.value`: 현재 입력창에 편집 중인 문자열이다. -- `submitted_inputs`: 현재 애플리케이션 세션에서 제출된 문자열의 순서 있는 목록이다. -- `#conversation`: 제출된 각 문자열을 `Label`로 표시하는 세션 내 대화 영역이다. +- `submitted_inputs`: `trim`된 유효 사용자 메시지 목록 +- `selected_responses`: 각 사용자 메시지에 대응하는 선택 응답 목록 -입력 제출이 성공하면 원문을 `submitted_inputs`에 추가하고 같은 문자열을 대화 영역에 표시한 뒤 입력창을 비운다. 빈 문자열은 추가하지 않는다. +두 목록은 같은 인덱스를 사용하며, 유효한 제출 하나마다 항목 하나씩 추가된다. 공백 입력은 어느 목록도 변경하지 않는다. -이 모델은 프로세스 메모리에만 존재한다. 파일, 데이터베이스, 외부 서비스에는 기록하지 않으며 애플리케이션 종료 시 상태가 폐기된다. LLM 응답이나 메시지 작성자 구분은 초기 범위에 포함하지 않는다. +## 고정 응답 목록 + +`src/chat_tui/app.py`의 `RESPONSE_MESSAGES` 상수는 다음 다섯 문자열을 순서대로 보유한다. + +1. `안녕하세요! 무엇을 도와드릴까요?` +2. `그것은 제가 처리하기 불가능합니다.` +3. `맡겨만 주세요.` +4. `지금 처리 중입니다.` +5. `작업을 완료하였습니다.` + +응답은 `random.choice(RESPONSE_MESSAGES)`로 선택한다. 난수 선택 자체는 테스트에서 `chat_tui.app.random.choice`를 대체할 수 있는 경계로 취급한다. + +## 대화 항목 + +유효한 입력이 제출되면 대화 영역에 다음 두 `Label`을 순서대로 추가한다. + +1. `user-message` 클래스의 사용자 메시지 +2. `response-message` 클래스의 선택 응답 + +사용자 메시지는 입력값을 `strip()`한 결과이며, 선택 응답은 고정 응답 목록의 문자열이다. + +## 불변 조건 + +- 공백 제거 결과가 빈 문자열이면 상태와 화면을 변경하지 않는다. +- 유효한 입력마다 사용자 메시지가 응답보다 먼저 추가된다. +- `submitted_inputs`와 `selected_responses`의 길이는 항상 같다. +- 응답은 고정 목록에 포함된 값이어야 한다. +- 대화 기록은 애플리케이션 세션 안에서만 유지한다. diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 2d55a9e..4ec0e04 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -1,24 +1,38 @@ # 인터페이스 계약 -## tui-input-display +## 메시지 제출 이벤트 -### 화면 +Textual의 `Input.Submitted` 이벤트가 `#user-input`에서 발생하면 `ChatApp.on_input_submitted`가 처리한다. -- Textual 애플리케이션은 헤더, `#conversation` 대화 영역, `#user-input` 입력창을 제공한다. -- 애플리케이션 시작 시 입력창에 포커스를 둔다. +### 입력 정규화 -### 입력 제출 +1. 이벤트의 `value`에 `strip()`을 적용한다. +2. 입력 위젯의 값을 빈 문자열로 초기화한다. +3. 정규화 결과가 빈 문자열이면 추가 처리를 중단한다. -- 사용자가 `#user-input`에서 Enter를 누르면 현재 값이 제출된다. -- 비어 있지 않은 값은 변경 없이 대화 영역에 새 항목으로 추가된다. -- 제출 후 입력창은 빈 값이 된다. -- 대화 영역의 항목 순서는 제출 순서와 같다. +공백만 포함한 입력은 사용자 메시지, 응답 선택, 대화 항목을 생성하지 않는다. -### 종료 +### 유효한 입력 처리 -- `Escape`와 `Ctrl+Q`는 애플리케이션의 종료 동작을 호출한다. -- 종료 시 세션 내 메모리 상태를 영구 저장하지 않는다. +정규화 결과가 비어 있지 않으면 다음 순서로 처리한다. -### 경계 +1. 정규화된 사용자 메시지를 `submitted_inputs`에 추가한다. +2. `random.choice(RESPONSE_MESSAGES)`로 응답을 선택한다. +3. 선택 응답을 `selected_responses`에 추가한다. +4. 대화 영역에 사용자 메시지 `Label`을 추가한다. +5. 바로 이어서 응답 `Label`을 추가한다. +6. 대화 영역을 마지막 항목까지 스크롤한다. -이 인터페이스는 화면 표시와 입력 상태 관리만 담당한다. LLM 클라이언트, 네트워크 요청, 인증 및 외부 서비스 연동은 제공하지 않는다. +사용자 메시지와 응답은 별도 대화 항목이며, 화면 표시 순서는 항상 사용자 메시지 다음 응답이다. + +## 화면 위젯 계약 + +- 대화 영역은 `#conversation` 식별자의 `VerticalScroll`이다. +- 입력창은 `#user-input` 식별자의 `Input`이다. +- 사용자 메시지는 `user-message` 클래스를 사용한다. +- 응답은 `response-message` 클래스를 사용한다. +- 애플리케이션 마운트 시 입력창에 포커스를 둔다. + +## 종료 계약 + +`Escape`와 `Ctrl+Q`는 기존 `quit` 바인딩을 통해 애플리케이션 종료를 요청한다. diff --git a/docs/project-structure.md b/docs/project-structure.md index 83025c7..ed42754 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -1,23 +1,32 @@ # 프로젝트 구조 -```text -. -├── pyproject.toml -├── README.md -├── src/ -│ └── chat_tui/ -│ ├── __init__.py -│ ├── __main__.py -│ └── app.py -├── tests/ -│ └── test_app.py -└── docs/ - ├── data-model.md - ├── interface-contract.md - ├── project-structure.md - ├── requirements/ - │ └── req-001_project-scaffold.md - └── runtime-flow.md -``` +## 애플리케이션 구현 -`pyproject.toml`은 Python 3.11 이상, Textual 런타임 의존성, 선택적 테스트 의존성, 콘솔 진입점을 선언한다. `src/chat_tui/app.py`는 Textual 애플리케이션과 실행 함수를 제공하고, `tests/test_app.py`는 입력 표시와 종료 계약을 검증한다. +- `src/chat_tui/app.py` + - `RESPONSE_MESSAGES`: 수정하지 않는 고정 응답 목록 + - `ChatApp.on_input_submitted`: 입력 `trim`, 유효성 판정, 난수 응답 선택, 상태 갱신 및 대화 항목 표시 + - `ChatApp.submitted_inputs`: 정규화된 사용자 메시지 세션 상태 + - `ChatApp.selected_responses`: 메시지별 선택 응답 세션 상태 + - 기존 Textual 위젯 구성과 `Escape`, `Ctrl+Q` 종료 바인딩 + +응답 기능은 별도 서비스나 외부 모듈을 만들지 않고 현재 단일 TUI 애플리케이션의 제출 이벤트 처리에 배치한다. 난수 선택은 표준 라이브러리 `random.choice`를 직접 사용한다. + +## 테스트 + +- `tests/test_app.py` + - 사용자 메시지와 응답의 제출 순서 및 화면 순서 + - `random.choice` 결과 제어와 호출 인자 + - `trim`된 입력 저장 + - 공백 입력 무시 + - 입력창 초기화 + - `Escape` 종료 요청 + +테스트는 Textual의 `run_test()`를 사용해 실제 위젯 트리와 이벤트 흐름을 검증하고, 난수 선택만 `unittest.mock.patch`로 제어한다. + +## 문서 + +- `docs/requirements/req-001_project-scaffold.md`: 기능 범위와 수용 기준 +- `docs/data-model.md`: 응답 목록과 세션 상태 모델 +- `docs/interface-contract.md`: 제출 이벤트와 화면 표시 계약 +- `docs/project-structure.md`: 기능 구성 요소와 책임 +- `docs/runtime-flow.md`: 실행 시나리오와 처리 순서 diff --git a/docs/requirements/req-001_project-scaffold.md b/docs/requirements/req-001_project-scaffold.md index 4ba6dde..339dbf4 100644 --- a/docs/requirements/req-001_project-scaffold.md +++ b/docs/requirements/req-001_project-scaffold.md @@ -8,19 +8,37 @@ Python 3.11 이상과 표준 `pyproject.toml`을 사용하는 Textual 기반 TUI 1. `chat-tui` 콘솔 명령과 `python -m chat_tui` 실행을 지원한다. 2. Textual 화면에 사용자 입력창과 대화 영역을 표시한다. -3. 사용자가 입력을 제출하면 입력한 문자열을 세션 내 대화 영역에 순서대로 누적한다. -4. 제출 뒤 입력창을 비운다. -5. `Escape` 또는 `Ctrl+Q`로 애플리케이션 종료를 요청할 수 있다. -6. 입력 표시와 종료 동작을 자동 테스트로 검증한다. +3. 사용자가 입력을 제출하면 입력값을 `trim`한 뒤 비어 있지 않은 사용자 메시지를 제출 순서대로 대화 영역에 누적한다. +4. 유효한 사용자 메시지 제출 후 고정된 다섯 개 응답 목록에서 표준 난수 선택으로 하나의 응답을 결정한다. +5. 선택한 응답을 해당 사용자 메시지 바로 뒤에 별도의 대화 항목으로 표시한다. +6. 유효한 입력 처리 뒤 입력창을 비운다. +7. 빈 입력 또는 공백만 포함한 입력은 사용자 메시지와 응답을 대화 기록에 추가하지 않는다. +8. `Escape` 또는 `Ctrl+Q`로 애플리케이션 종료를 요청할 수 있다. +9. 입력 누적, 응답 선택과 표시, 공백 입력 무시, 입력창 초기화 및 종료 동작을 자동 테스트로 검증한다. + +## 고정 응답 목록 + +응답은 다음 다섯 문구 중 하나를 사용한다. + +- 안녕하세요! 무엇을 도와드릴까요? +- 그것은 제가 처리하기 불가능합니다. +- 맡겨만 주세요. +- 지금 처리 중입니다. +- 작업을 완료하였습니다. + +응답 선택에는 표준 라이브러리의 난수 선택 기능을 사용하며, 외부 서비스나 LLM은 호출하지 않는다. ## 제외 범위 -LLM 연동, 응답 생성, 외부 API 호출, 인증, 영구 대화 기록 저장은 구현하지 않는다. +LLM 연동, 외부 API 호출, 인증, 영구 대화 기록 저장, 사용자별 응답 기록, 응답 목록 편집, 사용자 입력에 따른 맞춤형 응답 생성은 구현하지 않는다. ## 수용 기준 - Python 3.11 이상 환경에서 의존성을 설치할 수 있다. - TUI가 실행되고 입력창에 포커스가 배치된다. -- 두 번 이상 제출한 입력이 제출 순서대로 모두 표시된다. -- 빈 입력은 대화 기록에 추가되지 않는다. -- 자동 테스트가 입력 누적 표시와 종료 요청을 검증한다. +- 두 번 이상 제출한 입력이 `trim`된 값으로 제출 순서대로 표시된다. +- 각 유효한 사용자 메시지 바로 뒤에 고정 목록에서 선택된 응답이 표시된다. +- 자동 테스트에서 난수 선택 결과를 제어하고 선택 호출 및 표시 순서를 검증할 수 있다. +- 빈 입력과 공백만 포함한 입력은 사용자 메시지, 응답 및 응답 선택 결과를 남기지 않는다. +- 유효한 입력 제출 뒤 입력창이 비워진다. +- `Escape` 및 `Ctrl+Q`가 애플리케이션 종료를 요청한다. diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index 010ac2e..70fe035 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -1,13 +1,39 @@ -# 실행 흐름 +# 런타임 흐름 -## tui-input-loop +## 정상 입력 시나리오 -1. `chat-tui` 또는 `python -m chat_tui`가 `main()`을 호출한다. -2. `ChatApp`이 Textual 이벤트 루프를 시작하고 화면을 구성한다. -3. 입력창에 포커스를 둔 뒤 사용자의 입력을 기다린다. -4. 사용자가 Enter를 누르면 `Input.Submitted` 이벤트를 처리한다. -5. 비어 있지 않은 입력을 세션 목록에 추가하고 같은 값을 대화 영역의 새 `Label`로 마운트한다. -6. 입력창을 비우고 대화 영역을 마지막 항목으로 스크롤한 뒤 다시 입력을 기다린다. -7. 사용자가 `Escape` 또는 `Ctrl+Q`를 누르면 Textual 종료 동작을 호출한다. +1. 애플리케이션이 `#user-input`에 포커스를 둔다. +2. 사용자가 입력하고 Enter를 누른다. +3. `Input.Submitted` 이벤트에서 입력값을 `strip()`한다. +4. 입력창 값을 즉시 빈 문자열로 초기화한다. +5. `trim` 결과가 유효하면 사용자 메시지를 세션 상태에 기록한다. +6. `random.choice(RESPONSE_MESSAGES)`가 고정 응답 하나를 선택한다. +7. 선택 응답을 세션 상태에 기록한다. +8. 대화 영역에 사용자 메시지 `Label`을 추가한다. +9. 사용자 메시지 바로 뒤에 응답 `Label`을 추가한다. +10. 대화 영역을 마지막 항목으로 스크롤한다. -이 흐름에는 LLM 호출이나 외부 네트워크 요청이 없다. 애플리케이션 종료 뒤 세션 목록과 화면 항목은 보존되지 않는다. +여러 번 제출하면 각 사용자 메시지와 응답 쌍이 제출 순서대로 누적된다. + +## 공백 입력 시나리오 + +1. 사용자가 공백만 포함한 값을 제출한다. +2. 입력값을 `strip()`한다. +3. 결과가 빈 문자열임을 확인한다. +4. 입력창은 비우되 상태, 난수 선택 결과 및 대화 영역은 변경하지 않는다. + +## 종료 시나리오 + +- `Escape`를 누르면 기존 종료 바인딩이 실행된다. +- `Ctrl+Q`를 누르면 기존 종료 바인딩이 실행된다. +- 두 동작 모두 `quit` 액션을 통해 애플리케이션 종료를 요청한다. + +## 검증 시나리오 + +자동 테스트는 결정 가능한 `random.choice` 결과를 사용해 다음을 확인한다. + +- 입력 `trim` 결과가 사용자 메시지로 저장되고 표시되는지 +- 사용자 메시지 다음에 응답이 표시되는지 +- 공백 입력에서 `random.choice`가 호출되지 않는지 +- 입력창이 제출 후 비워지는지 +- 기존 종료 동작이 유지되는지 diff --git a/src/chat_tui/app.py b/src/chat_tui/app.py index 373bb78..5193496 100644 --- a/src/chat_tui/app.py +++ b/src/chat_tui/app.py @@ -1,12 +1,23 @@ -"""사용자 입력을 세션 내 대화 영역에 표시하는 Textual 애플리케이션.""" +"""사용자 입력과 고정 무작위 응답을 표시하는 Textual 애플리케이션.""" + +import random from textual.app import App, ComposeResult from textual.containers import VerticalScroll from textual.widgets import Header, Input, Label +RESPONSE_MESSAGES = ( + "안녕하세요! 무엇을 도와드릴까요?", + "그것은 제가 처리하기 불가능합니다.", + "맡겨만 주세요.", + "지금 처리 중입니다.", + "작업을 완료하였습니다.", +) + + class ChatApp(App[None]): - """LLM 호출 없이 사용자의 제출 입력만 표시하는 초기 TUI.""" + """사용자 메시지 뒤에 고정 목록에서 선택한 응답을 표시하는 TUI.""" CSS = """ Screen { @@ -32,6 +43,7 @@ class ChatApp(App[None]): def __init__(self) -> None: super().__init__() self.submitted_inputs: list[str] = [] + self.selected_responses: list[str] = [] def compose(self) -> ComposeResult: yield Header(show_clock=False) @@ -45,15 +57,20 @@ class ChatApp(App[None]): self.query_one("#user-input", Input).focus() def on_input_submitted(self, event: Input.Submitted) -> None: - """제출된 입력을 기록하고 대화 영역에 누적 표시한다.""" - value = event.value + """입력을 정규화하고 사용자 메시지와 무작위 응답을 표시한다.""" + value = event.value.strip() + event.input.value = "" + if not value: return + response = random.choice(RESPONSE_MESSAGES) self.submitted_inputs.append(value) + self.selected_responses.append(response) + conversation = self.query_one("#conversation", VerticalScroll) conversation.mount(Label(value, classes="user-message")) - event.input.value = "" + conversation.mount(Label(response, classes="response-message")) conversation.scroll_end(animate=False) diff --git a/tests/test_app.py b/tests/test_app.py index 5072f5c..8ecda51 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -3,29 +3,73 @@ from unittest.mock import patch from textual.widgets import Input, Label -from chat_tui.app import ChatApp +from chat_tui.app import RESPONSE_MESSAGES, ChatApp class ChatAppTest(unittest.IsolatedAsyncioTestCase): - async def test_submitted_input_is_displayed_and_accumulated(self) -> None: + async def test_submitted_input_and_response_are_displayed_in_order(self) -> None: app = ChatApp() - async with app.run_test() as pilot: - input_widget = app.query_one("#user-input", Input) - input_widget.value = "첫 번째 입력" - await pilot.press("enter") - await pilot.pause() + with patch("chat_tui.app.random.choice", return_value=RESPONSE_MESSAGES[2]): + async with app.run_test() as pilot: + input_widget = app.query_one("#user-input", Input) + input_widget.value = " 첫 번째 입력 " + await pilot.press("enter") + await pilot.pause() - input_widget.value = "두 번째 입력" - await pilot.press("enter") - await pilot.pause() + input_widget.value = "두 번째 입력" + await pilot.press("enter") + await pilot.pause() - self.assertEqual( - app.submitted_inputs, - ["첫 번째 입력", "두 번째 입력"], - ) - displayed = [label.renderable for label in app.query("#conversation Label")] - self.assertEqual(displayed, ["첫 번째 입력", "두 번째 입력"]) - self.assertEqual(input_widget.value, "") + self.assertEqual( + app.submitted_inputs, + ["첫 번째 입력", "두 번째 입력"], + ) + self.assertEqual( + app.selected_responses, + [RESPONSE_MESSAGES[2], RESPONSE_MESSAGES[2]], + ) + displayed = [ + label.renderable + for label in app.query("#conversation Label") + ] + self.assertEqual( + displayed, + [ + "첫 번째 입력", + RESPONSE_MESSAGES[2], + "두 번째 입력", + RESPONSE_MESSAGES[2], + ], + ) + self.assertEqual(input_widget.value, "") + + async def test_response_selection_uses_standard_random_choice(self) -> None: + app = ChatApp() + with patch( + "chat_tui.app.random.choice", + return_value=RESPONSE_MESSAGES[4], + ) as choice_mock: + async with app.run_test() as pilot: + input_widget = app.query_one("#user-input", Input) + input_widget.value = "처리할 입력" + await pilot.press("enter") + await pilot.pause() + + choice_mock.assert_called_once_with(RESPONSE_MESSAGES) + + async def test_whitespace_input_is_not_added(self) -> None: + app = ChatApp() + with patch("chat_tui.app.random.choice") as choice_mock: + async with app.run_test() as pilot: + input_widget = app.query_one("#user-input", Input) + input_widget.value = " \t \n" + await pilot.press("enter") + await pilot.pause() + + self.assertEqual(app.submitted_inputs, []) + self.assertEqual(app.selected_responses, []) + self.assertEqual(len(app.query("#conversation Label")), 0) + choice_mock.assert_not_called() async def test_empty_input_is_not_added(self) -> None: app = ChatApp() @@ -34,6 +78,7 @@ class ChatAppTest(unittest.IsolatedAsyncioTestCase): await pilot.pause() self.assertEqual(app.submitted_inputs, []) + self.assertEqual(app.selected_responses, []) self.assertEqual(len(app.query("#conversation Label")), 0) async def test_escape_requests_application_exit(self) -> None: