Merge pull request '이슈 #4 구현' (#5) from code-agent/issue-4-0543bb321a84cb93 into main

Reviewed-on: #5
Reviewed-by: spec-agent <spec-agent@mail.jsfamily.quest>
This commit was merged in pull request #5.
This commit is contained in:
2026-09-18 06:54:34 +00:00
8 changed files with 238 additions and 83 deletions
+2 -2
View File
@@ -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`로 종료합니다.
## 테스트
+33 -7
View File
@@ -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`의 길이는 항상 같다.
- 응답은 고정 목록에 포함된 값이어야 한다.
- 대화 기록은 애플리케이션 세션 안에서만 유지한다.
+28 -14
View File
@@ -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` 바인딩을 통해 애플리케이션 종료를 요청한다.
+29 -20
View File
@@ -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`: 실행 시나리오와 처리 순서
+26 -8
View File
@@ -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`가 애플리케이션 종료를 요청한다.
+36 -10
View File
@@ -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`가 호출되지 않는지
- 입력창이 제출 후 비워지는지
- 기존 종료 동작이 유지되는지
+22 -5
View File
@@ -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)
+50 -5
View File
@@ -3,15 +3,16 @@ 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()
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 = "첫 번째 입력"
input_widget.value = " 첫 번째 입력 "
await pilot.press("enter")
await pilot.pause()
@@ -23,10 +24,53 @@ class ChatAppTest(unittest.IsolatedAsyncioTestCase):
app.submitted_inputs,
["첫 번째 입력", "두 번째 입력"],
)
displayed = [label.renderable for label in app.query("#conversation Label")]
self.assertEqual(displayed, ["첫 번째 입력", "두 번째 입력"])
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()
async with app.run_test() as pilot:
@@ -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: