byoung-hf commited on
Commit
63256f9
·
verified ·
1 Parent(s): e75edb4

Upload folder using huggingface_hub

Browse files
.dockerignore CHANGED
@@ -1,6 +1,7 @@
1
  # Environment and secrets
2
  .env
3
  .env.*
 
4
 
5
  # Git
6
  .git
 
1
  # Environment and secrets
2
  .env
3
  .env.*
4
+ .venv
5
 
6
  # Git
7
  .git
.github/copilot-instructions.md CHANGED
@@ -22,9 +22,11 @@ The constitution covers:
22
 
23
  ## Quick Development Checklist
24
 
25
- - Run tests after refactoring: `uv run pytest src/test.py -v`
26
  - Always update notebooks when changing function signatures
27
- - Use `uv` for all code execution (never `pip` directly)
 
 
28
  - See `TESTING.md` for detailed test setup
29
 
30
  ## Common Gotchas & Reminders
 
22
 
23
  ## Quick Development Checklist
24
 
25
+ - Run tests after refactoring: `uv run pytest tests/ -v`
26
  - Always update notebooks when changing function signatures
27
+ - Use `uv` for all code execution (never `pip` directly, never manually activate venv)
28
+ - Use `uv run` to execute scripts and commands, never bare `python` or shell activation
29
+ - **NEVER use `tail`, `head`, `grep`, or similar output filters** — show full output always so you can see everything that's happening
30
  - See `TESTING.md` for detailed test setup
31
 
32
  ## Common Gotchas & Reminders
.github/workflows/update_space.yml CHANGED
@@ -23,13 +23,24 @@ jobs:
23
  GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
24
  GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
25
  run: |
 
26
  docker run --rm \
27
  -e OPENAI_API_KEY="${OPENAI_API_KEY}" \
28
  -e GROQ_API_KEY="${GROQ_API_KEY}" \
29
  -e GITHUB_PERSONAL_ACCESS_TOKEN="${GITHUB_PERSONAL_ACCESS_TOKEN}" \
 
 
30
  --entrypoint uv \
31
  ai-me:test \
32
- run pytest src/test.py -v -s
 
 
 
 
 
 
 
 
33
 
34
  deploy:
35
  needs: test
 
23
  GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
24
  GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
25
  run: |
26
+ rm -rf ${{ github.workspace }}/htmlcov
27
  docker run --rm \
28
  -e OPENAI_API_KEY="${OPENAI_API_KEY}" \
29
  -e GROQ_API_KEY="${GROQ_API_KEY}" \
30
  -e GITHUB_PERSONAL_ACCESS_TOKEN="${GITHUB_PERSONAL_ACCESS_TOKEN}" \
31
+ -v "${{ github.workspace }}/htmlcov:/app/htmlcov" \
32
+ --user 0 \
33
  --entrypoint uv \
34
  ai-me:test \
35
+ run pytest tests/ -v --cov=src --cov-report=html --cov-report=term-missing
36
+
37
+ - name: Upload coverage reports as artifact
38
+ uses: actions/upload-artifact@v4
39
+ if: always()
40
+ with:
41
+ name: coverage-report
42
+ path: htmlcov/
43
+ retention-days: 30
44
 
45
  deploy:
46
  needs: test
.specify/memory/constitution.md CHANGED
@@ -104,7 +104,7 @@ All software must maintain complete traceability between requirements, implement
104
  2. **Local Development**:
105
  - Use `docs/` directory for markdown (won't deploy unless pushed to GitHub repo)
106
  - Test locally: `uv run src/app.py` (Gradio on port 7860)
107
- - Run tests: `uv run pytest src/test.py -v`
108
  - Edit notebooks then validate changes don't break tests
109
 
110
  3. **Docker/Notebook Development**:
@@ -123,10 +123,10 @@ All software must maintain complete traceability between requirements, implement
123
  - `src/data.py` - DataManager class, complete document pipeline
124
  - `src/agent.py` - AIMeAgent class, MCP setup, agent creation
125
  - `src/app.py` - Gradio interface, session management
126
- - `src/test.py` - Integration tests with pytest-asyncio
127
  - `src/notebooks/experiments.ipynb` - Development sandbox (test all APIs here first)
 
 
128
  - `docs/` - Local markdown for RAG development
129
- - `test_data/` - Test fixtures and sample data
130
  - `.github/copilot-instructions.md` - Detailed AI assistant guidance
131
  - `.specify/` - Spec-Driven Development templates and memory
132
 
 
104
  2. **Local Development**:
105
  - Use `docs/` directory for markdown (won't deploy unless pushed to GitHub repo)
106
  - Test locally: `uv run src/app.py` (Gradio on port 7860)
107
+ - Run tests: `uv run pytest tests/ -v`
108
  - Edit notebooks then validate changes don't break tests
109
 
110
  3. **Docker/Notebook Development**:
 
123
  - `src/data.py` - DataManager class, complete document pipeline
124
  - `src/agent.py` - AIMeAgent class, MCP setup, agent creation
125
  - `src/app.py` - Gradio interface, session management
 
126
  - `src/notebooks/experiments.ipynb` - Development sandbox (test all APIs here first)
127
+ - `tests/integration/spec-001.py` - Integration tests for spec 001 (personified AI agent)
128
+ - `tests/data/` - Test fixtures and sample data
129
  - `docs/` - Local markdown for RAG development
 
130
  - `.github/copilot-instructions.md` - Detailed AI assistant guidance
131
  - `.specify/` - Spec-Driven Development templates and memory
132
 
Dockerfile CHANGED
@@ -22,12 +22,19 @@ RUN mkdir -p /app/bin \
22
 
23
  WORKDIR /app
24
 
25
- # Install project dependencies with uv
26
  COPY pyproject.toml uv.lock ./
27
- RUN uv sync
28
 
 
 
 
 
 
29
  COPY . /app
30
 
 
 
 
31
  # Non-root user with access to /app
32
  RUN adduser -u 5678 --disabled-password --gecos "" appuser && chown -R appuser /app
33
  USER appuser
 
22
 
23
  WORKDIR /app
24
 
25
+ # Copy only dependency specifications for layer caching
26
  COPY pyproject.toml uv.lock ./
 
27
 
28
+ # Create virtual environment and sync dependencies from lock file
29
+ # --no-install-project defers building the local package until source is copied
30
+ RUN uv venv && uv sync --locked --no-install-project
31
+
32
+ # Now copy the complete source code
33
  COPY . /app
34
 
35
+ # Sync again to install the local package (now that source is present)
36
+ RUN uv sync --locked
37
+
38
  # Non-root user with access to /app
39
  RUN adduser -u 5678 --disabled-password --gecos "" appuser && chown -R appuser /app
40
  USER appuser
TESTING.md CHANGED
@@ -2,7 +2,7 @@
2
 
3
  ## Overview
4
 
5
- The test suite (`src/test.py`) validates the ai-me agent system including:
6
  - Vectorstore setup and document loading
7
  - Agent configuration and initialization
8
  - RAG (Retrieval Augmented Generation) functionality
@@ -29,13 +29,32 @@ The test suite (`src/test.py`) validates the ai-me agent system including:
29
  From project root:
30
  ```bash
31
  # All tests
32
- uv run pytest src/test.py -v
33
 
34
  # With detailed output
35
- uv run pytest src/test.py -v -o log_cli=true --log-cli-level=INFO --capture=no
36
 
37
  # Specific test
38
- uv run pytest src/test.py::test_rear_knowledge_contains_it245 -v -o log_cli=true --log-cli-level=INFO --capture=no
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  ```
40
 
41
  ## Test Architecture
@@ -49,7 +68,7 @@ uv run pytest src/test.py::test_rear_knowledge_contains_it245 -v -o log_cli=true
49
  **Configuration**:
50
  - **Temperature**: Set to 0.0 for deterministic, reproducible responses
51
  - **Model**: Uses model specified in config (default: `openai/openai/gpt-oss-120b` via Groq)
52
- - **Data Source**: `test_data/` directory (configured via `doc_root` parameter)
53
  - **GitHub Repos**: Disabled (`GITHUB_REPOS=""`) for faster test execution
54
 
55
  The temperature of 0 ensures that the agent's responses are consistent across test runs, making assertions more reliable.
 
2
 
3
  ## Overview
4
 
5
+ The test suite (`tests/integration/spec-001.py`) validates the ai-me agent system including:
6
  - Vectorstore setup and document loading
7
  - Agent configuration and initialization
8
  - RAG (Retrieval Augmented Generation) functionality
 
29
  From project root:
30
  ```bash
31
  # All tests
32
+ uv run pytest tests/ -v
33
 
34
  # With detailed output
35
+ uv run pytest tests/ -v -o log_cli=true --log-cli-level=INFO --capture=no
36
 
37
  # Specific test
38
+ uv run pytest tests/integration/spec-001.py::test_rear_knowledge_contains_it245 -v -o log_cli=true --log-cli-level=INFO --capture=no
39
+ ```
40
+
41
+ ### Run Tests with Code Coverage
42
+
43
+ ```bash
44
+ # Run tests with coverage report
45
+ uv run pytest tests/ --cov=src --cov-report=term-missing -v
46
+
47
+ # Generate HTML coverage report
48
+ uv run pytest tests/ --cov=src --cov-report=html -v
49
+
50
+ # View HTML report (opens in browser)
51
+ open htmlcov/index.html
52
+
53
+ # Integration tests only with coverage
54
+ uv run pytest tests/integration/ --cov=src --cov-report=term-missing -v
55
+
56
+ # Show only uncovered lines
57
+ uv run pytest tests/ --cov=src --cov-report=term:skip-covered -v
58
  ```
59
 
60
  ## Test Architecture
 
68
  **Configuration**:
69
  - **Temperature**: Set to 0.0 for deterministic, reproducible responses
70
  - **Model**: Uses model specified in config (default: `openai/openai/gpt-oss-120b` via Groq)
71
+ - **Data Source**: `tests/data/` directory (configured via `doc_root` parameter)
72
  - **GitHub Repos**: Disabled (`GITHUB_REPOS=""`) for faster test execution
73
 
74
  The temperature of 0 ensures that the agent's responses are consistent across test runs, making assertions more reliable.
pyproject.toml CHANGED
@@ -1,3 +1,7 @@
 
 
 
 
1
  [project]
2
  name = "ai-me"
3
  version = "0.1.0"
@@ -34,4 +38,45 @@ dev = [
34
  "ipywidgets~=8.1",
35
  "pytest~=8.0",
36
  "pytest-asyncio~=0.24",
 
37
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
  [project]
6
  name = "ai-me"
7
  version = "0.1.0"
 
38
  "ipywidgets~=8.1",
39
  "pytest~=8.0",
40
  "pytest-asyncio~=0.24",
41
+ "pytest-cov~=6.0",
42
  ]
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src"]
46
+
47
+ [tool.hatch.build.targets.editable]
48
+ packages = ["src"]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
52
+ python_files = ["test_*.py", "*_test.py", "spec-*.py"]
53
+ python_classes = ["Test*", "*Tests"]
54
+ python_functions = ["test_*"]
55
+ pythonpath = ["src"]
56
+
57
+ [tool.coverage.run]
58
+ source = ["src"]
59
+ omit = [
60
+ "*/tests/*",
61
+ "*/test_*",
62
+ "*/__pycache__/*",
63
+ "*/notebooks/*",
64
+ ]
65
+
66
+ [tool.coverage.report]
67
+ exclude_lines = [
68
+ "pragma: no cover",
69
+ "def __repr__",
70
+ "raise AssertionError",
71
+ "raise NotImplementedError",
72
+ "if __name__ == .__main__.:",
73
+ "if TYPE_CHECKING:",
74
+ "class .*\\bProtocol\\):",
75
+ "@(abc\\.)?abstractmethod",
76
+ ]
77
+ show_missing = true
78
+ precision = 2
79
+ fail_under = 85
80
+
81
+ [tool.coverage.html]
82
+ directory = "htmlcov"
pyrightconfig.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "typeCheckingMode": "standard",
3
+ "include": ["src", "tests"],
4
+ "exclude": ["**/node_modules", "**/__pycache__", ".venv"],
5
+ "extraPaths": ["src"]
6
+ }
specs/001-personified-ai-agent/spec.md CHANGED
@@ -205,16 +205,16 @@ A user asks the agent a question, and the agent provides a response with clear r
205
  | Requirement | User Stories | Success Criteria | Implementation Modules | Tests |
206
  |---|---|---|---|---|
207
  | [**FR-001**](#fr-001-chat-interface) (Chat Interface) | [US1](#user-story-1---chat-with-personified-agent-about-expertise-priority-p1), [US2](#user-story-2---interact-across-multiple-conversation-topics-priority-p2) | [SC-001](#sc-001-validates-fr-003), [SC-005](#sc-005-validates-nfr-001), [SC-006](#sc-006-validates-nfr-002) | [`src/app.py::initialize_session()`](../../src/app.py), [`src/app.py::chat()`](../../src/app.py) | [`test_user_story_2_multi_topic_consistency()`](../../src/test.py) |
208
- | [**FR-002**](#fr-002-knowledge-retrieval) (Knowledge Retrieval) | [US1](#user-story-1---chat-with-personified-agent-about-expertise-priority-p1) | [SC-002](#sc-002-validates-fr-002-fr-004), [SC-003](#sc-003-validates-fr-006) | [`src/data.py::load_local_documents()`](../../src/data.py), [`src/data.py::load_github_documents()`](../../src/data.py), [`src/data.py::chunk_documents()`](../../src/data.py), [`src/data.py::create_vectorstore()`](../../src/data.py) | [`test_rear_knowledge_contains_it245()`](../../src/test.py), [`test_carol_knowledge_contains_product()`](../../src/test.py), [`test_user_story_3_source_attribution()`](../../src/test.py) |
209
  | [**FR-003**](#fr-003-first-person-persona) (First-Person Persona) | [US1](#user-story-1---chat-with-personified-agent-about-expertise-priority-p1), [US2](#user-story-2---interact-across-multiple-conversation-topics-priority-p2) | [SC-001](#sc-001-validates-fr-003) | [`src/agent.py::create_ai_me_agent()`](../../src/agent.py), [`src/agent.py::run()`](../../src/agent.py) | [`test_rear_knowledge_contains_it245()`](../../src/test.py), [`test_carol_knowledge_contains_product()`](../../src/test.py), [`test_user_story_2_multi_topic_consistency()`](../../src/test.py) |
210
- | [**FR-004**](#fr-004-source-attribution) (Source Attribution) | [US3](#user-story-3---access-sourced-information-with-attribution-priority-p2) | [SC-002](#sc-002-validates-fr-002-fr-004) | [`src/data.py::process_documents()`](../../src/data.py), [`src/agent.py::get_local_info_tool()`](../../src/agent.py) | [`test_github_relative_links_converted_to_absolute_urls()`](../../src/test.py), [`test_user_story_3_source_attribution()`](../../src/test.py) |
211
  | [**FR-005**](#fr-005-session-history) (Session History) | [US1](#user-story-1---chat-with-personified-agent-about-expertise-priority-p1), [US2](#user-story-2---interact-across-multiple-conversation-topics-priority-p2) | [SC-005](#sc-005-validates-nfr-001) | [`src/app.py::chat()`](../../src/app.py) | [`test_user_story_2_multi_topic_consistency()`](../../src/test.py) |
212
  | [**FR-006**](#fr-006-knowledge-gap-handling) (Knowledge Gap Handling) | [US2](#user-story-2---interact-across-multiple-conversation-topics-priority-p2) | [SC-003](#sc-003-validates-fr-006), [SC-004](#sc-004-validates-fr-006) | [`src/agent.py::run()`](../../src/agent.py) | [`test_unknown_person_contains_negative_response()`](../../src/test.py) |
213
  | [**FR-007**](#fr-007-session-isolation) (Session Isolation) | [US2](#user-story-2---interact-across-multiple-conversation-topics-priority-p2) | [SC-006](#sc-006-validates-nfr-002) | [`src/app.py::initialize_session()`](../../src/app.py), [`src/app.py::get_session_status()`](../../src/app.py), [`src/app.py::chat()`](../../src/app.py) | [`test_user_story_2_multi_topic_consistency()`](../../src/test.py) |
214
  | [**FR-008**](#fr-008-output-normalization) (Output Normalization) | [US1](#user-story-1---chat-with-personified-agent-about-expertise-priority-p1) | [SC-001](#sc-001-validates-fr-003) | [`src/agent.py::run()`](../../src/agent.py) | All tests (implicit in response validation) |
215
  | [**FR-009**](#fr-009-mandatory-tools) (Mandatory Tools) | [US1](#user-story-1---chat-with-personified-agent-about-expertise-priority-p1) | [SC-005](#sc-005-validates-nfr-001), [SC-007](#sc-007-validates-fr-012) | [`src/agent.py::mcp_time_params`](../../src/agent.py), [`src/agent.py::get_mcp_memory_params()`](../../src/agent.py), [`src/agent.py::setup_mcp_servers()`](../../src/agent.py) | [`test_mcp_time_server_returns_current_date()`](../../src/test.py), [`test_mcp_memory_server_remembers_favorite_color()`](../../src/test.py) |
216
  | [**FR-010**](#fr-010-optional-tools) (Optional Tools) | [US1](#user-story-1---chat-with-personified-agent-about-expertise-priority-p1) | [SC-007](#sc-007-validates-fr-012) | [`src/agent.py::mcp_github_params`](../../src/agent.py), [`src/agent.py::setup_mcp_servers()`](../../src/agent.py), [`src/data.py::load_github_documents()`](../../src/data.py) | [`test_github_commits_contains_shas()`](../../src/test.py) |
217
- | [**FR-011**](#fr-011-conflict-resolution--logging) (Conflict Resolution) | [US3](#user-story-3---access-sourced-information-with-attribution-priority-p2) | [SC-002](#sc-002-validates-fr-002-fr-004) | [`src/data.py::load_github_documents()`](../../src/data.py), [`src/agent.py::get_local_info_tool()`](../../src/agent.py) | [`test_user_story_3_source_attribution()`](../../src/test.py) |
218
  | [**FR-012**](#fr-012-tool-error-handling) (Tool Error Handling) | [US1](#user-story-1---chat-with-personified-agent-about-expertise-priority-p1), [US3](#user-story-3---access-sourced-information-with-attribution-priority-p2) | [SC-007](#sc-007-validates-fr-012) | [`src/agent.py::setup_mcp_servers()`](../../src/agent.py), [`src/agent.py::run()`](../../src/agent.py), [`src/agent.py::cleanup()`](../../src/agent.py) | [`test_tool_failure_error_messages_are_friendly()`](../../src/test.py) |
219
  | [**FR-013**](#fr-013-memory-tool) (Memory Tool) | [US2](#user-story-2---interact-across-multiple-conversation-topics-priority-p2) | [SC-008](#sc-008-validates-fr-013) | [`src/agent.py::get_mcp_memory_params()`](../../src/agent.py) | [`test_mcp_memory_server_remembers_favorite_color()`](../../src/test.py) |
220
 
@@ -224,7 +224,7 @@ A user asks the agent a question, and the agent provides a response with clear r
224
  |---|---|---|---|---|
225
  | [**NFR-001**](#nfr-001-sub-5s-response) (Sub-5s Response) | [US1](#user-story-1---chat-with-personified-agent-about-expertise-priority-p1), [US2](#user-story-2---interact-across-multiple-conversation-topics-priority-p2) | [SC-005](#sc-005-validates-nfr-001) | [`src/agent.py::run()`](../../src/agent.py), [`src/data.py::create_vectorstore()`](../../src/data.py) | [`test_mcp_time_server_returns_current_date()`](../../src/test.py) |
226
  | [**NFR-002**](#nfr-002-10-concurrent-sessions) (10+ Concurrent Sessions) | [US2](#user-story-2---interact-across-multiple-conversation-topics-priority-p2) | [SC-006](#sc-006-validates-nfr-002) | [`src/app.py::initialize_session()`](../../src/app.py), [`src/app.py::chat()`](../../src/app.py), [`src/agent.py::AIMeAgent`](../../src/agent.py) | [`test_user_story_2_multi_topic_consistency()`](../../src/test.py), [`test_mcp_memory_server_remembers_favorite_color()`](../../src/test.py) |
227
- | [**NFR-003**](#nfr-003-structured-logging) (Structured Logging) | [US1](#user-story-1---chat-with-personified-agent-about-expertise-priority-p1), [US3](#user-story-3---access-sourced-information-with-attribution-priority-p2) | [SC-007](#sc-007-validates-fr-012) | [`src/config.py::setup_logger()`](../../src/config.py), [`src/agent.py::run()`](../../src/agent.py), [`src/app.py::chat()`](../../src/app.py) | [`test_user_story_3_source_attribution()`](../../src/test.py), [`test_tool_failure_error_messages_are_friendly()`](../../src/test.py) |
228
  | [**NFR-004**](#nfr-004-unicode-normalization) (Unicode Normalization) | [US1](#user-story-1---chat-with-personified-agent-about-expertise-priority-p1) | [SC-001](#sc-001-validates-fr-003) | [`src/agent.py::run()`](../../src/agent.py) | All tests (implicit in response validation) |
229
  | [**NFR-005**](#nfr-005-session-isolation) (Session Isolation) | [US2](#user-story-2---interact-across-multiple-conversation-topics-priority-p2) | [SC-006](#sc-006-validates-nfr-002) | [`src/app.py::initialize_session()`](../../src/app.py), [`src/agent.py::cleanup()`](../../src/agent.py) | [`test_user_story_2_multi_topic_consistency()`](../../src/test.py) |
230
 
 
205
  | Requirement | User Stories | Success Criteria | Implementation Modules | Tests |
206
  |---|---|---|---|---|
207
  | [**FR-001**](#fr-001-chat-interface) (Chat Interface) | [US1](#user-story-1---chat-with-personified-agent-about-expertise-priority-p1), [US2](#user-story-2---interact-across-multiple-conversation-topics-priority-p2) | [SC-001](#sc-001-validates-fr-003), [SC-005](#sc-005-validates-nfr-001), [SC-006](#sc-006-validates-nfr-002) | [`src/app.py::initialize_session()`](../../src/app.py), [`src/app.py::chat()`](../../src/app.py) | [`test_user_story_2_multi_topic_consistency()`](../../src/test.py) |
208
+ | [**FR-002**](#fr-002-knowledge-retrieval) (Knowledge Retrieval) | [US1](#user-story-1---chat-with-personified-agent-about-expertise-priority-p1) | [SC-002](#sc-002-validates-fr-002-fr-004), [SC-003](#sc-003-validates-fr-006) | [`src/data.py::load_local_documents()`](../../src/data.py), [`src/data.py::load_github_documents()`](../../src/data.py), [`src/data.py::chunk_documents()`](../../src/data.py), [`src/data.py::create_vectorstore()`](../../src/data.py) | [`test_rear_knowledge_contains_it245()`](../../src/test.py), [`test_carol_knowledge_contains_product()`](../../src/test.py), [`test_github_relative_links_converted_to_absolute_urls()`](../../src/test.py) |
209
  | [**FR-003**](#fr-003-first-person-persona) (First-Person Persona) | [US1](#user-story-1---chat-with-personified-agent-about-expertise-priority-p1), [US2](#user-story-2---interact-across-multiple-conversation-topics-priority-p2) | [SC-001](#sc-001-validates-fr-003) | [`src/agent.py::create_ai_me_agent()`](../../src/agent.py), [`src/agent.py::run()`](../../src/agent.py) | [`test_rear_knowledge_contains_it245()`](../../src/test.py), [`test_carol_knowledge_contains_product()`](../../src/test.py), [`test_user_story_2_multi_topic_consistency()`](../../src/test.py) |
210
+ | [**FR-004**](#fr-004-source-attribution) (Source Attribution) | [US3](#user-story-3---access-sourced-information-with-attribution-priority-p2) | [SC-002](#sc-002-validates-fr-002-fr-004) | [`src/data.py::process_documents()`](../../src/data.py), [`src/agent.py::get_local_info_tool()`](../../src/agent.py) | [`test_github_relative_links_converted_to_absolute_urls()`](../../src/test.py) |
211
  | [**FR-005**](#fr-005-session-history) (Session History) | [US1](#user-story-1---chat-with-personified-agent-about-expertise-priority-p1), [US2](#user-story-2---interact-across-multiple-conversation-topics-priority-p2) | [SC-005](#sc-005-validates-nfr-001) | [`src/app.py::chat()`](../../src/app.py) | [`test_user_story_2_multi_topic_consistency()`](../../src/test.py) |
212
  | [**FR-006**](#fr-006-knowledge-gap-handling) (Knowledge Gap Handling) | [US2](#user-story-2---interact-across-multiple-conversation-topics-priority-p2) | [SC-003](#sc-003-validates-fr-006), [SC-004](#sc-004-validates-fr-006) | [`src/agent.py::run()`](../../src/agent.py) | [`test_unknown_person_contains_negative_response()`](../../src/test.py) |
213
  | [**FR-007**](#fr-007-session-isolation) (Session Isolation) | [US2](#user-story-2---interact-across-multiple-conversation-topics-priority-p2) | [SC-006](#sc-006-validates-nfr-002) | [`src/app.py::initialize_session()`](../../src/app.py), [`src/app.py::get_session_status()`](../../src/app.py), [`src/app.py::chat()`](../../src/app.py) | [`test_user_story_2_multi_topic_consistency()`](../../src/test.py) |
214
  | [**FR-008**](#fr-008-output-normalization) (Output Normalization) | [US1](#user-story-1---chat-with-personified-agent-about-expertise-priority-p1) | [SC-001](#sc-001-validates-fr-003) | [`src/agent.py::run()`](../../src/agent.py) | All tests (implicit in response validation) |
215
  | [**FR-009**](#fr-009-mandatory-tools) (Mandatory Tools) | [US1](#user-story-1---chat-with-personified-agent-about-expertise-priority-p1) | [SC-005](#sc-005-validates-nfr-001), [SC-007](#sc-007-validates-fr-012) | [`src/agent.py::mcp_time_params`](../../src/agent.py), [`src/agent.py::get_mcp_memory_params()`](../../src/agent.py), [`src/agent.py::setup_mcp_servers()`](../../src/agent.py) | [`test_mcp_time_server_returns_current_date()`](../../src/test.py), [`test_mcp_memory_server_remembers_favorite_color()`](../../src/test.py) |
216
  | [**FR-010**](#fr-010-optional-tools) (Optional Tools) | [US1](#user-story-1---chat-with-personified-agent-about-expertise-priority-p1) | [SC-007](#sc-007-validates-fr-012) | [`src/agent.py::mcp_github_params`](../../src/agent.py), [`src/agent.py::setup_mcp_servers()`](../../src/agent.py), [`src/data.py::load_github_documents()`](../../src/data.py) | [`test_github_commits_contains_shas()`](../../src/test.py) |
217
+ | [**FR-011**](#fr-011-conflict-resolution--logging) (Conflict Resolution) | [US3](#user-story-3---access-sourced-information-with-attribution-priority-p2) | [SC-002](#sc-002-validates-fr-002-fr-004) | [`src/data.py::load_github_documents()`](../../src/data.py), [`src/agent.py::get_local_info_tool()`](../../src/agent.py) | [`test_github_relative_links_converted_to_absolute_urls()`](../../src/test.py) |
218
  | [**FR-012**](#fr-012-tool-error-handling) (Tool Error Handling) | [US1](#user-story-1---chat-with-personified-agent-about-expertise-priority-p1), [US3](#user-story-3---access-sourced-information-with-attribution-priority-p2) | [SC-007](#sc-007-validates-fr-012) | [`src/agent.py::setup_mcp_servers()`](../../src/agent.py), [`src/agent.py::run()`](../../src/agent.py), [`src/agent.py::cleanup()`](../../src/agent.py) | [`test_tool_failure_error_messages_are_friendly()`](../../src/test.py) |
219
  | [**FR-013**](#fr-013-memory-tool) (Memory Tool) | [US2](#user-story-2---interact-across-multiple-conversation-topics-priority-p2) | [SC-008](#sc-008-validates-fr-013) | [`src/agent.py::get_mcp_memory_params()`](../../src/agent.py) | [`test_mcp_memory_server_remembers_favorite_color()`](../../src/test.py) |
220
 
 
224
  |---|---|---|---|---|
225
  | [**NFR-001**](#nfr-001-sub-5s-response) (Sub-5s Response) | [US1](#user-story-1---chat-with-personified-agent-about-expertise-priority-p1), [US2](#user-story-2---interact-across-multiple-conversation-topics-priority-p2) | [SC-005](#sc-005-validates-nfr-001) | [`src/agent.py::run()`](../../src/agent.py), [`src/data.py::create_vectorstore()`](../../src/data.py) | [`test_mcp_time_server_returns_current_date()`](../../src/test.py) |
226
  | [**NFR-002**](#nfr-002-10-concurrent-sessions) (10+ Concurrent Sessions) | [US2](#user-story-2---interact-across-multiple-conversation-topics-priority-p2) | [SC-006](#sc-006-validates-nfr-002) | [`src/app.py::initialize_session()`](../../src/app.py), [`src/app.py::chat()`](../../src/app.py), [`src/agent.py::AIMeAgent`](../../src/agent.py) | [`test_user_story_2_multi_topic_consistency()`](../../src/test.py), [`test_mcp_memory_server_remembers_favorite_color()`](../../src/test.py) |
227
+ | [**NFR-003**](#nfr-003-structured-logging) (Structured Logging) | [US1](#user-story-1---chat-with-personified-agent-about-expertise-priority-p1), [US3](#user-story-3---access-sourced-information-with-attribution-priority-p2) | [SC-007](#sc-007-validates-fr-012) | [`src/config.py::setup_logger()`](../../src/config.py), [`src/agent.py::run()`](../../src/agent.py), [`src/app.py::chat()`](../../src/app.py) | [`test_github_relative_links_converted_to_absolute_urls()`](../../src/test.py), [`test_tool_failure_error_messages_are_friendly()`](../../src/test.py) |
228
  | [**NFR-004**](#nfr-004-unicode-normalization) (Unicode Normalization) | [US1](#user-story-1---chat-with-personified-agent-about-expertise-priority-p1) | [SC-001](#sc-001-validates-fr-003) | [`src/agent.py::run()`](../../src/agent.py) | All tests (implicit in response validation) |
229
  | [**NFR-005**](#nfr-005-session-isolation) (Session Isolation) | [US2](#user-story-2---interact-across-multiple-conversation-topics-priority-p2) | [SC-006](#sc-006-validates-nfr-002) | [`src/app.py::initialize_session()`](../../src/app.py), [`src/agent.py::cleanup()`](../../src/agent.py) | [`test_user_story_2_multi_topic_consistency()`](../../src/test.py) |
230
 
specs/002-linkedin-profile-extractor/INTEGRATION_GUIDE.md CHANGED
@@ -301,9 +301,10 @@ Only **publicly visible** LinkedIn data:
301
  **Solution**:
302
  ```bash
303
  # Test data loading
304
- python -c "from src.data import DataManager;
305
- dm = DataManager();
306
- docs = dm.process_documents();
 
307
  print(f'Loaded {len(docs)} documents')"
308
  ```
309
 
 
301
  **Solution**:
302
  ```bash
303
  # Test data loading
304
+ python -c "from src.data import DataManager, DataManagerConfig;
305
+ config = DataManagerConfig();
306
+ dm = DataManager(config=config);
307
+ docs = dm.load_local_documents();
308
  print(f'Loaded {len(docs)} documents')"
309
  ```
310
 
src/agent.py CHANGED
@@ -3,16 +3,19 @@ Agent configuration and MCP server setup.
3
  Handles agent-specific configuration like MCP servers and prompts.
4
  """
5
  import json
 
 
6
  import traceback
7
- from typing import List, Dict, Any, Optional
8
 
9
  from pydantic import BaseModel, Field, computed_field, ConfigDict, SecretStr
10
  from agents import Agent, Tool, function_tool, Runner
11
  from agents.result import RunResult
12
  from agents.run import RunConfig
13
- from agents.mcp import MCPServerStdio
 
14
  from config import setup_logger
15
-
16
  logger = setup_logger(__name__)
17
 
18
  # Unicode normalization translation table - built once, reused for all responses
@@ -51,6 +54,173 @@ class AIMeAgent(BaseModel):
51
 
52
  model_config = ConfigDict(arbitrary_types_allowed=True)
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  @computed_field
55
  @property
56
  def mcp_github_params(self) -> MCPServerParams:
@@ -65,8 +235,6 @@ class AIMeAgent(BaseModel):
65
  The official version supports --toolsets and --read-only flags.
66
  We use read-only mode with a limited toolset for safety.
67
  """
68
- import os
69
-
70
  # Use local binary for testing, production path in Docker
71
  test_binary = "/tmp/test-github-mcp/github-mcp-server"
72
  prod_binary = "/app/bin/github-mcp-server"
@@ -118,9 +286,6 @@ class AIMeAgent(BaseModel):
118
  Returns:
119
  MCPServerParams configured with session-specific memory file
120
  """
121
- import tempfile
122
- import os
123
-
124
  # Create session-specific memory file in temp directory
125
  temp_dir = tempfile.gettempdir()
126
  memory_file = os.path.join(temp_dir, f"mcp_memory_{session_id}.json")
@@ -132,117 +297,52 @@ class AIMeAgent(BaseModel):
132
  description="Memory MCP Server"
133
  )
134
 
135
- @computed_field
136
  @property
137
  def agent_prompt(self) -> str:
138
- """Generate agent prompt template."""
139
  return f"""
140
  You are acting as somebody who is personifying {self.bot_full_name}.
141
  Your primary role is to help users by answering questions about my knowledge,
142
- experience, and expertise in technology. When interacting with the user follow
143
- these rules:
144
- - always refer to yourself as {self.bot_full_name} or "I".
145
- - When talking about a prior current or prior employer indicate the relationship
146
- clearly. For example: Neosofia (my current employer) or Medidata (a prior
147
- employer).
148
- - You should be personable, friendly, and professional in your responses.
149
- - You should note information about the user in your memory to improve future
150
- interactions.
151
- - You should use the tools available to you to look up information as needed.
152
- - If the user asks a question ALWAYS USE THE get_local_info tool ONCE to gather
153
- info from my documentation (this is RAG-based)
154
- - Format file references as complete GitHub URLs with owner, repo, path, and
155
- filename
156
- - Example: https://github.com/owner/repo/blob/main/filename.md
157
- - Never use shorthand like: filename.md†L44-L53 or source†L44-L53
158
- - Always strip out line number references
159
- - CRITICAL: Include source citations in your response to establish credibility
160
- and traceability. Format citations as:
161
- - For GitHub sources: "Per my [document_name]..." or "As mentioned in [document_name]..."
162
- - For local sources: "According to my documentation on [topic]..."
163
- - Include the source URL in parentheses when available
164
- - Example: "Per my resume (https://github.com/byoung/ai-me/blob/main/resume.md), I worked at..."
165
- - Add reference links in a references section at the end of the output if they
166
- match github.com
167
- - Below are critical instructions for using your memory and GitHub tools
168
- effectively.
169
-
170
- MEMORY USAGE - MANDATORY WORKFLOW FOR EVERY USER MESSAGE:
171
- 1. FIRST ACTION - Read Current Memory:
172
- - Call read_graph() to see ALL existing entities and their observations
173
- - This prevents errors when adding observations to entities
174
- 2. User Identification:
175
- - Assume you are interacting with a user entity (e.g., "user_john" if they
176
- say "I'm John")
177
- - If the user entity doesn't exist in the graph yet, you MUST create it first
178
- 3. Gather New Information:
179
- - Pay attention to new information about the user:
180
- a) Basic Identity (name, age, gender, location, job title, education, etc.)
181
- b) Behaviors (interests, habits, activities, etc.)
182
- c) Preferences (communication style, preferred language, topics of
183
- interest, etc.)
184
- d) Goals (aspirations, targets, objectives, etc.)
185
- e) Relationships (personal and professional connections)
186
- 4. Update Memory - CRITICAL ORDER:
187
- - STEP 1: Create missing entities using create_entities() for any new
188
- people, organizations, or events
189
- - STEP 2: ONLY AFTER entities exist, add facts using add_observations() to
190
- existing entities
191
- - STEP 3: Connect related entities using create_relations()
192
- EXAMPLE - User says "Hi, I'm Alice":
193
- ✓ Correct order:
194
- 1. read_graph() - check if user_alice exists
195
- 2. create_entities(entities=[{{"name": "user_alice", "entityType": "person",
196
- "observations": ["Name is Alice"]}}])
197
- 3. respond to user
198
- ✗ WRONG - will cause errors:
199
- 1. add_observations(entityName="user_alice",
200
- observations=["Name is Alice"]) - ERROR: entity not found!
201
- ALWAYS create entities BEFORE adding observations to them.
202
 
203
- GITHUB TOOLS RESTRICTIONS - IMPORTANT:
204
- DO NOT USE ANY GITHUB TOOL MORE THAN THREE TIMES PER SESSION.
205
- You have access to these GitHub tools ONLY:
206
- - search_code: to look for code snippets and references supporting your
207
- answers
208
- - get_file_contents: for getting source code (NEVER download .md markdown
209
- files)
210
- - list_commits: for getting commit history for a specific user
211
- CRITICAL RULES FOR search_code TOOL:
212
- The search_code tool searches ALL of GitHub by default. You MUST add
213
- owner/repo filters to EVERY search_code query.
214
- REQUIRED FORMAT: Always include one of these filters in the query parameter:
215
- - user:byoung (to search byoung's repos)
216
- - org:Neosofia (to search Neosofia's repos)
217
- - repo:byoung/ai-me (specific repo)
218
- - repo:Neosofia/corporate (specific repo)
219
- EXAMPLES OF CORRECT search_code USAGE:
220
- - search_code(query="python user:byoung")
221
- - search_code(query="docker org:Neosofia")
222
- - search_code(query="ReaR repo:Neosofia/corporate")
223
- EXAMPLES OF INCORRECT search_code USAGE (NEVER DO THIS):
224
- - search_code(query="python")
225
- - search_code(query="ReaR")
226
- - search_code(query="bash script")
227
- CRITICAL RULES FOR get_file_contents TOOL:
228
- The get_file_contents tool accepts ONLY these parameters: owner, repo, path
229
- DO NOT use 'ref' parameter - it will cause errors. The tool always reads from
230
- the main/default branch.
231
- EXAMPLES OF CORRECT get_file_contents USAGE:
232
- - get_file_contents(owner="Neosofia", repo="corporate",
233
- path="website/qms/policies.md")
234
- - get_file_contents(owner="byoung", repo="ai-me", path="README.md")
235
- EXAMPLES OF INCORRECT get_file_contents USAGE (NEVER DO THIS):
236
- - get_file_contents(owner="Neosofia", repo="corporate",
237
- path="website/qms/policies.md", ref="main")
238
- - get_file_contents(owner="byoung", repo="ai-me", path="README.md",
239
- ref="master")
240
  """
241
 
242
  async def setup_mcp_servers(self, mcp_params_list: List[MCPServerParams]):
243
- """Initialize and connect all MCP servers from provided parameters list.
244
 
245
- Implements FR-009 (Mandatory Tools), FR-010 (Optional Tools), FR-012 (Tool Error Handling).
 
246
  """
247
 
248
  mcp_servers_local = []
@@ -254,12 +354,14 @@ EXAMPLES OF INCORRECT get_file_contents USAGE (NEVER DO THIS):
254
  logger.debug(f"Args: {params.args}")
255
  logger.debug(f"Env vars: {list(params.env.keys()) if params.env else 'None'}")
256
 
257
- server = MCPServerStdio(params.model_dump(), client_session_timeout_seconds=30)
 
 
258
  await server.connect()
259
  logger.info(f"✓ {server_name} connected successfully")
260
  mcp_servers_local.append(server)
261
 
262
- except Exception as e:
263
  logger.error(f"✗ {server_name} failed to connect")
264
  logger.error(f" Error type: {type(e).__name__}")
265
  logger.error(f" Error message: {e}")
@@ -318,59 +420,111 @@ EXAMPLES OF INCORRECT get_file_contents USAGE (NEVER DO THIS):
318
 
319
  async def create_ai_me_agent(
320
  self,
321
- agent_prompt: str = None,
322
  mcp_params: Optional[List[MCPServerParams]] = None,
323
- additional_tools: Optional[List[Tool]] = None,
324
  ) -> Agent:
325
- """Create the main ai-me agent.
 
 
 
 
 
 
 
 
 
 
 
326
 
327
- Implements FR-001 (Chat Interface), FR-003 (First-Person Persona), FR-009 (Mandatory Tools),
328
- FR-010 (Optional Tools).
329
-
330
  Args:
331
- agent_prompt: Optional prompt override. If None, uses self.agent_prompt.
332
- mcp_params: Optional list of MCP server parameters to initialize.
333
- If None or empty, no MCP servers will be initialized. To use memory
334
- functionality, caller must explicitly pass mcp_params including
335
- get_mcp_memory_params(session_id) with a unique session_id.
336
- additional_tools: Optional list of additional tools to append to
337
- the default get_local_info tool. The get_local_info tool is
338
- always included as the first tool.
339
  Returns:
340
  An initialized Agent instance.
341
  """
342
  # Setup MCP servers if any params provided
343
- mcp_servers = await self.setup_mcp_servers(mcp_params) if mcp_params else None
344
 
345
  # Store MCP servers for cleanup
346
- if mcp_servers:
347
- self._mcp_servers = mcp_servers
348
 
349
- # Use provided prompt or fall back to default
350
- prompt = agent_prompt if agent_prompt is not None else self.agent_prompt
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
  logger.debug(f"Creating ai-me agent with prompt: {prompt[:100]}...")
352
 
353
  # Build tools list - get_local_info is always the default first tool
354
  tools = [self.get_local_info_tool()]
355
-
356
- # Append any additional tools provided
357
- if additional_tools:
358
- tools.extend(additional_tools)
359
 
360
  logger.info(f"Creating ai-me agent with tools: {[tool.name for tool in tools]}")
361
 
362
- # Pass MCP servers directly to main agent instead of wrapping in sub-agent
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
  agent_kwargs = {
364
  "model": self.model,
365
  "name": "ai-me",
366
  "instructions": prompt,
367
  "tools": tools,
368
  }
369
-
370
- # Only add mcp_servers if we have them
371
  if mcp_servers:
372
  agent_kwargs["mcp_servers"] = mcp_servers
373
-
 
 
 
 
374
  ai_me = Agent(**agent_kwargs)
375
 
376
  # Print all available tools after agent initialization
@@ -394,8 +548,10 @@ EXAMPLES OF INCORRECT get_file_contents USAGE (NEVER DO THIS):
394
  async def run(self, user_input: str, **runner_kwargs) -> str:
395
  """Run the agent and post-process output to remove Unicode brackets.
396
 
397
- Implements FR-001 (Chat Interface), FR-003 (First-Person Persona), FR-008 (Output Normalization),
398
- FR-012 (Tool Error Handling), NFR-001 (Sub-5s Response), NFR-003 (Structured Logging), NFR-004 (Unicode Normalization).
 
 
399
  """
400
  # Log user input with session context
401
  session_prefix = f"[Session: {self.session_id[:8]}...] " if self.session_id else ""
@@ -411,8 +567,8 @@ EXAMPLES OF INCORRECT get_file_contents USAGE (NEVER DO THIS):
411
  **runner_kwargs)
412
  except Exception as e:
413
  error_str = str(e).lower()
414
-
415
- if "rate limit" in error_str or "api rate limit exceeded" in error_str:
416
  logger.warning(f"{session_prefix}GitHub rate limit exceeded")
417
  return "⚠️ GitHub rate limit exceeded. Try asking me again in 30 seconds"
418
  else:
@@ -438,16 +594,13 @@ EXAMPLES OF INCORRECT get_file_contents USAGE (NEVER DO THIS):
438
 
439
  Implements FR-012 (Tool Error Handling), NFR-005 (Session Isolation).
440
  """
441
- if not self._mcp_servers:
442
- return
443
-
444
  session_prefix = f"[Session: {self.session_id[:8]}...] " if self.session_id else ""
445
  logger.debug(f"{session_prefix}Cleaning up {len(self._mcp_servers)} MCP servers...")
446
 
447
  for server in self._mcp_servers:
448
  try:
449
  await server.cleanup()
450
- except Exception as e:
451
  # Log but don't fail - best effort cleanup
452
  logger.debug(f"{session_prefix}Error cleaning up MCP server: {e}")
453
 
 
3
  Handles agent-specific configuration like MCP servers and prompts.
4
  """
5
  import json
6
+ import os
7
+ import tempfile
8
  import traceback
9
+ from typing import List, Dict, Any, Optional, ClassVar
10
 
11
  from pydantic import BaseModel, Field, computed_field, ConfigDict, SecretStr
12
  from agents import Agent, Tool, function_tool, Runner
13
  from agents.result import RunResult
14
  from agents.run import RunConfig
15
+ from agents.mcp import MCPServerStdio, MCPServerStdioParams
16
+
17
  from config import setup_logger
18
+
19
  logger = setup_logger(__name__)
20
 
21
  # Unicode normalization translation table - built once, reused for all responses
 
54
 
55
  model_config = ConfigDict(arbitrary_types_allowed=True)
56
 
57
+ # Static prompt sections - these don't need instance data
58
+ MEMORY_AGENT_PROMPT: ClassVar[str] = """
59
+ 🚨 MEMORY MANAGEMENT - THIS SECTION MUST BE FOLLOWED EXACTLY 🚨
60
+
61
+ YOU MUST use the read_graph tool at the START of EVERY user interaction.
62
+ The read_graph tool is DIFFERENT from all other tools - it takes NO input parameters.
63
+
64
+ CRITICAL SYNTAX FOR read_graph:
65
+ - read_graph is called with ZERO arguments
66
+ - NO curly braces: read_graph
67
+ - NO parentheses with content: read_graph() ← This is the ONLY correct form
68
+ - NO empty object: read_graph({}) ← WRONG - will cause 400 error
69
+ - NO empty string key: read_graph({"": {}}) ← WRONG - will cause 400 error
70
+ - NO parameters at all: read_graph ← Correct but less clear
71
+ - The correct way: read_graph() with empty parentheses but NO content inside
72
+
73
+ When calling read_graph:
74
+ ✅ CORRECT: read_graph() with nothing inside the parentheses
75
+ ❌ WRONG: read_graph({}), read_graph({"":""}), read_graph(params={}), read_graph(data=None)
76
+
77
+ WORKFLOW FOR EVERY MESSAGE:
78
+ 1. Call read_graph() immediately - retrieve all stored information
79
+ 2. Check if "user" entity exists in the returned knowledge graph
80
+ 3. If the user shares new information:
81
+ a) If "user" entity doesn't exist: create_entities(
82
+ entities=[{"name":"user","entityType":"person",
83
+ "observations":["..."]}])
84
+ b) If "user" entity exists: add_observations(
85
+ observations=[{"entityName":"user","contents":["..."]}])
86
+ 4. If user asks about stored info: search read_graph results and respond
87
+
88
+ TOOLS REFERENCE:
89
+ - read_graph() ← Takes ZERO parameters, returns all stored data
90
+ - create_entities(entities=[...]) ← Takes entities array
91
+ - add_observations(observations=[...]) ← Takes observations array
92
+ - create_relations(relations=[...]) ← Takes relations array
93
+
94
+ EXAMPLES:
95
+
96
+ User says "My favorite color is blue":
97
+ 1. read_graph() ← Call with empty parentheses
98
+ 2. See if "user" entity exists
99
+ 3. If not: create_entities(
100
+ entities=[{"name":"user","entityType":"person",
101
+ "observations":["favorite color is blue"]}])
102
+ 4. If yes: add_observations(
103
+ observations=[{"entityName":"user",
104
+ "contents":["favorite color is blue"]}])
105
+ 5. Reply: "Got it, I'll remember that your favorite color is blue."
106
+
107
+ User asks "What's my favorite color":
108
+ 1. read_graph() ← Call with empty parentheses FIRST
109
+ 2. Find "user" entity in returned graph
110
+ 3. Look for observation about color
111
+ 4. Reply with the stored information
112
+
113
+ MEMORY ENTITY STRUCTURE:
114
+ - Entity name: "user" (the user you're talking to)
115
+ - Entity type: "person"
116
+ - Observations: Array of facts about them (["likes red", "from NYC", "engineer"])
117
+ """
118
+
119
+ GITHUB_RESEARCHER_PROMPT: ClassVar[str] = """
120
+ You are the GitHub Researcher, responsible for researching the Bot's professional
121
+ portfolio on GitHub.
122
+
123
+ Your responsibilities:
124
+ - Search for code, projects, and commits on GitHub
125
+ - Retrieve file contents from repositories
126
+ - Provide context about technical work and contributions
127
+
128
+ GITHUB TOOLS RESTRICTIONS - IMPORTANT:
129
+ DO NOT USE ANY GITHUB TOOL MORE THAN THREE TIMES PER REQUEST.
130
+ You have access to these GitHub tools ONLY:
131
+ - search_code: to look for code snippets and references supporting your
132
+ answers
133
+ - get_file_contents: for getting source code (NEVER download .md markdown
134
+ files)
135
+ - list_commits: for getting commit history for a specific user
136
+
137
+ CRITICAL RULES FOR search_code TOOL:
138
+ The search_code tool searches ALL of GitHub by default. You MUST add
139
+ owner/repo filters to EVERY search_code query.
140
+ REQUIRED FORMAT: Always include one of these filters in the query parameter:
141
+ - user:byoung (to search byoung's repos)
142
+ - org:Neosofia (to search Neosofia's repos)
143
+ - repo:byoung/ai-me (specific repo)
144
+ - repo:Neosofia/corporate (specific repo)
145
+
146
+ EXAMPLES OF CORRECT search_code USAGE:
147
+ - search_code(query="python user:byoung")
148
+ - search_code(query="docker org:Neosofia")
149
+ - search_code(query="ReaR repo:Neosofia/corporate")
150
+
151
+ EXAMPLES OF INCORRECT search_code USAGE (NEVER DO THIS):
152
+ - search_code(query="python")
153
+ - search_code(query="ReaR")
154
+ - search_code(query="bash script")
155
+
156
+ CRITICAL RULES FOR get_file_contents TOOL:
157
+ The get_file_contents tool accepts ONLY these parameters: owner, repo, path
158
+ DO NOT use 'ref' parameter - it will cause errors. The tool always reads from
159
+ the main/default branch.
160
+
161
+ EXAMPLES OF CORRECT get_file_contents USAGE:
162
+ - get_file_contents(owner="Neosofia", repo="corporate",
163
+ path="website/qms/policies.md")
164
+ - get_file_contents(owner="byoung", repo="ai-me", path="README.md")
165
+
166
+ EXAMPLES OF INCORRECT get_file_contents USAGE (NEVER DO THIS):
167
+ - get_file_contents(owner="Neosofia", repo="corporate",
168
+ path="website/qms/policies.md", ref="main")
169
+ - get_file_contents(owner="byoung", repo="ai-me", path="README.md",
170
+ ref="master")
171
+ """
172
+
173
+ KB_RESEARCHER_PROMPT: ClassVar[str] = """
174
+ KNOWLEDGE BASE RESEARCH - MANDATORY TOOL USAGE:
175
+
176
+ You MUST use get_local_info tool to answer ANY questions about my background,
177
+ experience, skills, education, projects, or expertise.
178
+
179
+ 🚨 CRITICAL RULES:
180
+ 1. When user asks about your background, skills, languages, experience → ALWAYS use get_local_info
181
+ 2. When you don't know something → use get_local_info before saying "I don't know"
182
+ 3. When user asks personal/professional questions → ALWAYS search knowledge base first
183
+ 4. Never say "I'm not familiar with that" without first trying get_local_info
184
+
185
+ MANDATORY WORKFLOW:
186
+ 1. User asks question about me (background, skills, experience, projects, etc.)
187
+ 2. IMMEDIATELY call: get_local_info(query="[user's question]")
188
+ 3. Review ALL returned documents carefully
189
+ 4. Formulate first-person response from the documents
190
+ 5. Include source references (file paths or document titles)
191
+
192
+ TOOL USAGE:
193
+ - get_local_info(query="Python programming languages skills") →
194
+ retrieves all documents about my skills
195
+ - get_local_info(query="background experience") → retrieves background info
196
+ - get_local_info(query="projects I've worked on") → retrieves project info
197
+
198
+ EXAMPLES:
199
+
200
+ User asks: "What programming languages are you skilled in?"
201
+ 1. Call: get_local_info(query="programming languages skills")
202
+ 2. Search returned docs for language list
203
+ 3. Respond: "I'm skilled in Python, Go, TypeScript, Rust, and SQL. I specialize in..."
204
+ 4. Include source like: "(from team documentation)"
205
+
206
+ User asks: "What is your background in technology?"
207
+ 1. Call: get_local_info(query="background experience technology")
208
+ 2. Find relevant background information
209
+ 3. Respond in first-person: "I specialize in backend systems and..."
210
+ 4. Cite sources
211
+
212
+ CRITICAL - DO NOT:
213
+ ❌ Say "I'm not familiar" without trying get_local_info first
214
+ ❌ Refuse to answer without searching the knowledge base
215
+ ❌ Make up information if get_local_info returns no results
216
+
217
+ Response Format:
218
+ - ALWAYS first-person (I, my, me)
219
+ - ALWAYS include source attribution
220
+ - ALWAYS use information from get_local_info results
221
+ - Format sources like: "(from team.md)" or "(from professional documentation)"
222
+ """
223
+
224
  @computed_field
225
  @property
226
  def mcp_github_params(self) -> MCPServerParams:
 
235
  The official version supports --toolsets and --read-only flags.
236
  We use read-only mode with a limited toolset for safety.
237
  """
 
 
238
  # Use local binary for testing, production path in Docker
239
  test_binary = "/tmp/test-github-mcp/github-mcp-server"
240
  prod_binary = "/app/bin/github-mcp-server"
 
286
  Returns:
287
  MCPServerParams configured with session-specific memory file
288
  """
 
 
 
289
  # Create session-specific memory file in temp directory
290
  temp_dir = tempfile.gettempdir()
291
  memory_file = os.path.join(temp_dir, f"mcp_memory_{session_id}.json")
 
297
  description="Memory MCP Server"
298
  )
299
 
 
300
  @property
301
  def agent_prompt(self) -> str:
302
+ """Generate main agent prompt."""
303
  return f"""
304
  You are acting as somebody who is personifying {self.bot_full_name}.
305
  Your primary role is to help users by answering questions about my knowledge,
306
+ experience, and expertise in technology.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
 
308
+ CRITICAL: You are NOT an all-knowing AI. You are personifying ME, {self.bot_full_name},
309
+ a specific person. You can ONLY answer based on MY documentation OR information about
310
+ the USER stored in memory. Do NOT use your general LLM training data to answer questions.
311
+
312
+ === CRITICAL WORKFLOW FOR EVERY USER MESSAGE ===
313
+
314
+ 1. **USER PERSONAL INFO** (they share or ask about THEIR information):
315
+ - User says "My favorite color is..." Use memory tools to store in knowledge graph
316
+ - User asks "What's my favorite color?" → Use memory tools to retrieve from knowledge graph
317
+ - Call read_graph() immediately, then create_entities/add_observations for new info
318
+
319
+ 2. **GITHUB/CODE QUERIES** (they ask about repositories, code, implementations):
320
+ - User asks "What's in repo X?" → Use GitHub search_code or get_file_contents tools
321
+ - User asks "Show me file Y" → Use get_file_contents to fetch content
322
+ - Use available GitHub tools to search and retrieve
323
+
324
+ 3. **YOUR BACKGROUND/KNOWLEDGE** (they ask about you, {self.bot_full_name}):
325
+ - User asks "What's your experience?" → Use get_local_info to retrieve documentation
326
+ - User asks "Do you know Carol?" → Use get_local_info to search knowledge base
327
+ - ALWAYS use get_local_info FIRST before saying you don't know something
328
+
329
+ === RESPONSE GUIDELINES ===
330
+
331
+ When formulating responses:
332
+ - Always refer to yourself as {self.bot_full_name} or "I"
333
+ - When mentioning employers: "Neosofia (my current employer)" or "Medidata (a prior employer)"
334
+ - Be personable, friendly, and professional
335
+ - Format GitHub URLs as complete paths: https://github.com/owner/repo/blob/main/path/file.md
336
+ - CRITICAL: Include source citations
337
+ - Example: "Per my resume (https://github.com/byoung/ai-me/blob/main/resume.md), I worked at..."
338
+ - Add reference links section at end if GitHub sources referenced
 
 
 
 
 
 
339
  """
340
 
341
  async def setup_mcp_servers(self, mcp_params_list: List[MCPServerParams]):
342
+ """Initialize and connect all MCP servers from provided parameters.
343
 
344
+ Implements FR-009 (Mandatory Tools), FR-010 (Optional Tools),
345
+ FR-012 (Tool Error Handling).
346
  """
347
 
348
  mcp_servers_local = []
 
354
  logger.debug(f"Args: {params.args}")
355
  logger.debug(f"Env vars: {list(params.env.keys()) if params.env else 'None'}")
356
 
357
+ # Construct a strongly-typed MCPServerStdioParams from the Pydantic model dict
358
+ server_params = MCPServerStdioParams(**params.model_dump())
359
+ server = MCPServerStdio(server_params, client_session_timeout_seconds=30)
360
  await server.connect()
361
  logger.info(f"✓ {server_name} connected successfully")
362
  mcp_servers_local.append(server)
363
 
364
+ except Exception as e: # pragma: no cover
365
  logger.error(f"✗ {server_name} failed to connect")
366
  logger.error(f" Error type: {type(e).__name__}")
367
  logger.error(f" Error message: {e}")
 
420
 
421
  async def create_ai_me_agent(
422
  self,
 
423
  mcp_params: Optional[List[MCPServerParams]] = None,
 
424
  ) -> Agent:
425
+ """Create the main ai-me agent with organized instruction sections.
426
+
427
+ Implements FR-001 (Chat Interface), FR-003 (First-Person Persona),
428
+ FR-009 (Mandatory Tools), FR-010 (Optional Tools).
429
+
430
+ The agent prompt is organized into sections providing specialized
431
+ instructions for different capabilities:
432
+ - Main persona and response guidelines
433
+ - Memory management (always included)
434
+ - GitHub research (always included)
435
+ - Knowledge base research (always included via get_local_info)
436
+ - Time utilities (always included)
437
 
 
 
 
438
  Args:
439
+ mcp_params: List of MCP server parameters to initialize.
440
+ Should include GitHub, Time, and Memory MCP servers.
441
+ Caller must pass get_mcp_memory_params(session_id) with a
442
+ unique session_id for proper session isolation.
443
+
 
 
 
444
  Returns:
445
  An initialized Agent instance.
446
  """
447
  # Setup MCP servers if any params provided
448
+ mcp_servers = await self.setup_mcp_servers(mcp_params) if mcp_params else []
449
 
450
  # Store MCP servers for cleanup
451
+ self._mcp_servers = mcp_servers
 
452
 
453
+ # Build comprehensive prompt from sections
454
+ # Start with main agent prompt
455
+ prompt_sections = [self.agent_prompt]
456
+
457
+ # Add KB Researcher instructions (always available)
458
+ prompt_sections.append("\n## Knowledge Base Research")
459
+ prompt_sections.append(self.KB_RESEARCHER_PROMPT)
460
+
461
+ # Add Time utility note (time server is always included)
462
+ prompt_sections.append("\n## Time Information")
463
+ prompt_sections.append(
464
+ "You have access to time tools for getting current "
465
+ "date/time information."
466
+ )
467
+
468
+ prompt = "\n".join(prompt_sections)
469
+
470
  logger.debug(f"Creating ai-me agent with prompt: {prompt[:100]}...")
471
 
472
  # Build tools list - get_local_info is always the default first tool
473
  tools = [self.get_local_info_tool()]
 
 
 
 
474
 
475
  logger.info(f"Creating ai-me agent with tools: {[tool.name for tool in tools]}")
476
 
477
+ # Separate GitHub and memory servers for sub-agent creation
478
+ github_mcp_servers = [s for s in mcp_servers if "github-mcp-server" in str(s)]
479
+ memory_mcp_servers = [s for s in mcp_servers if "server-memory" in str(s)]
480
+ time_mcp_servers = [s for s in mcp_servers if "mcp-server-time" in str(s)]
481
+
482
+ # Create GitHub sub-agent (always included)
483
+ github_agent = Agent(
484
+ name="github_agent",
485
+ handoff_description=(
486
+ "Handles GitHub research and code exploration"
487
+ ),
488
+ instructions=self.GITHUB_RESEARCHER_PROMPT,
489
+ tools=[],
490
+ mcp_servers=github_mcp_servers,
491
+ model=self.model,
492
+ )
493
+ logger.info(
494
+ f"✓ GitHub sub-agent created with "
495
+ f"{len(github_mcp_servers)} MCP server(s)"
496
+ )
497
+
498
+ # Create Memory sub-agent (always included)
499
+ memory_agent = Agent(
500
+ name="memory_agent",
501
+ handoff_description="Handles memory management and knowledge graph operations",
502
+ instructions=self.MEMORY_AGENT_PROMPT,
503
+ tools=[],
504
+ mcp_servers=memory_mcp_servers,
505
+ model=self.model,
506
+ )
507
+ logger.info(
508
+ f"✓ Memory sub-agent created with "
509
+ f"{len(memory_mcp_servers)} MCP server(s)"
510
+ )
511
+
512
+ # Create main agent with ALL MCP servers for direct execution
513
+ # Sub-agents have specialized prompts but access same tools for reliability
514
  agent_kwargs = {
515
  "model": self.model,
516
  "name": "ai-me",
517
  "instructions": prompt,
518
  "tools": tools,
519
  }
520
+
 
521
  if mcp_servers:
522
  agent_kwargs["mcp_servers"] = mcp_servers
523
+ logger.info(f"✓ {len(mcp_servers)} MCP servers added to main agent")
524
+
525
+ # Add both sub-agents as handoffs (always included)
526
+ agent_kwargs["handoffs"] = [github_agent, memory_agent]
527
+
528
  ai_me = Agent(**agent_kwargs)
529
 
530
  # Print all available tools after agent initialization
 
548
  async def run(self, user_input: str, **runner_kwargs) -> str:
549
  """Run the agent and post-process output to remove Unicode brackets.
550
 
551
+ Implements FR-001 (Chat Interface), FR-003 (First-Person Persona),
552
+ FR-008 (Output Normalization), FR-012 (Tool Error Handling),
553
+ NFR-001 (Sub-5s Response), NFR-003 (Structured Logging),
554
+ NFR-004 (Unicode Normalization).
555
  """
556
  # Log user input with session context
557
  session_prefix = f"[Session: {self.session_id[:8]}...] " if self.session_id else ""
 
567
  **runner_kwargs)
568
  except Exception as e:
569
  error_str = str(e).lower()
570
+
571
+ if "rate limit" in error_str or "api rate limit exceeded" in error_str: # pragma: no cover
572
  logger.warning(f"{session_prefix}GitHub rate limit exceeded")
573
  return "⚠️ GitHub rate limit exceeded. Try asking me again in 30 seconds"
574
  else:
 
594
 
595
  Implements FR-012 (Tool Error Handling), NFR-005 (Session Isolation).
596
  """
 
 
 
597
  session_prefix = f"[Session: {self.session_id[:8]}...] " if self.session_id else ""
598
  logger.debug(f"{session_prefix}Cleaning up {len(self._mcp_servers)} MCP servers...")
599
 
600
  for server in self._mcp_servers:
601
  try:
602
  await server.cleanup()
603
+ except Exception as e: # pragma: no cover
604
  # Log but don't fail - best effort cleanup
605
  logger.debug(f"{session_prefix}Error cleaning up MCP server: {e}")
606
 
src/app.py CHANGED
@@ -1,19 +1,18 @@
 
 
 
1
  from config import Config, setup_logger
2
  from agent import AIMeAgent
3
  from data import DataManager, DataManagerConfig
4
- import gradio
5
- from gradio import Request
6
 
7
  logger = setup_logger(__name__)
8
 
9
- config = Config()
10
 
11
  # Initialize data manager and vectorstore
12
- data_config = DataManagerConfig(
13
- github_repos=config.github_repos
14
- )
15
  data_manager = DataManager(config=data_config)
16
- vectorstore = data_manager.setup_vectorstore()
17
 
18
  # Per-session agent storage (keyed by Gradio session_hash)
19
  # Each session gets its own AIMeAgent instance with session-specific MCP servers
@@ -39,8 +38,7 @@ async def initialize_session(session_id: str) -> None:
39
  session_id=session_id # Pass session_id for logging context
40
  )
41
 
42
- # TBD: make this prompt more generic by removing byoung/Neosofia specific
43
- # references. The instructions are verbose because search_code tool is complex.
44
  await session_agent.create_ai_me_agent(
45
  mcp_params=[
46
  session_agent.mcp_github_params,
@@ -55,7 +53,8 @@ async def initialize_session(session_id: str) -> None:
55
  # Warmup: establish context and preload tools
56
  try:
57
  logger.info(f"[Session: {session_id[:8]}...] Running warmup...")
58
- await session_agent.run("Please introduce yourself briefly - who you are and what your main expertise is.")
 
59
  logger.info(f"[Session: {session_id[:8]}...] Warmup complete!")
60
  except Exception as e:
61
  logger.info(f"[Session: {session_id[:8]}...] Warmup failed: {e}")
@@ -67,6 +66,7 @@ async def get_session_status(request: Request):
67
  Implements FR-001 (Chat Interface), FR-007 (Session Isolation).
68
  """
69
  session_id = request.session_hash
 
70
  if session_id not in session_agents:
71
  await initialize_session(session_id)
72
  return ""
@@ -78,6 +78,7 @@ async def chat(user_input: str, history, request: Request):
78
  Implements FR-001 (Chat Interface), FR-005 (Session History), FR-007 (Session Isolation).
79
  """
80
  session_id = request.session_hash
 
81
 
82
  # Initialize agent for this session if not already done
83
  if session_id not in session_agents:
@@ -97,7 +98,7 @@ if __name__ == "__main__":
97
  custom_js = f.read()
98
 
99
  with gradio.Blocks(
100
- theme=gradio.themes.Default(),
101
  css=custom_css,
102
  fill_height=True,
103
  js=f"() => {{ {custom_js} }}"
 
1
+ import gradio
2
+ from gradio import Request, themes
3
+
4
  from config import Config, setup_logger
5
  from agent import AIMeAgent
6
  from data import DataManager, DataManagerConfig
 
 
7
 
8
  logger = setup_logger(__name__)
9
 
10
+ config = Config() # type: ignore
11
 
12
  # Initialize data manager and vectorstore
13
+ data_config = DataManagerConfig()
 
 
14
  data_manager = DataManager(config=data_config)
15
+ vectorstore = data_manager.setup_vectorstore(github_repos=config.github_repos) # type: ignore
16
 
17
  # Per-session agent storage (keyed by Gradio session_hash)
18
  # Each session gets its own AIMeAgent instance with session-specific MCP servers
 
38
  session_id=session_id # Pass session_id for logging context
39
  )
40
 
41
+ # Initialize agent with MCP servers for GitHub, Time, and Memory tools
 
42
  await session_agent.create_ai_me_agent(
43
  mcp_params=[
44
  session_agent.mcp_github_params,
 
53
  # Warmup: establish context and preload tools
54
  try:
55
  logger.info(f"[Session: {session_id[:8]}...] Running warmup...")
56
+ # Use a greeting that doesn't require tool calls or RAG retrieval
57
+ await session_agent.run("Hello!")
58
  logger.info(f"[Session: {session_id[:8]}...] Warmup complete!")
59
  except Exception as e:
60
  logger.info(f"[Session: {session_id[:8]}...] Warmup failed: {e}")
 
66
  Implements FR-001 (Chat Interface), FR-007 (Session Isolation).
67
  """
68
  session_id = request.session_hash
69
+ assert session_id is not None, "session_hash should always be set by Gradio"
70
  if session_id not in session_agents:
71
  await initialize_session(session_id)
72
  return ""
 
78
  Implements FR-001 (Chat Interface), FR-005 (Session History), FR-007 (Session Isolation).
79
  """
80
  session_id = request.session_hash
81
+ assert session_id is not None, "session_hash should always be set by Gradio"
82
 
83
  # Initialize agent for this session if not already done
84
  if session_id not in session_agents:
 
98
  custom_js = f.read()
99
 
100
  with gradio.Blocks(
101
+ theme=themes.Default(),
102
  css=custom_css,
103
  fill_height=True,
104
  js=f"() => {{ {custom_js} }}"
src/config.py CHANGED
@@ -2,12 +2,12 @@
2
  Configuration management for ai-me application.
3
  Centralizes environment variables, API clients, and application defaults.
4
  """
5
- import os
6
  import logging
 
7
  import socket
8
- from typing import Optional, List, Union
9
  from logging.handlers import QueueHandler, QueueListener
10
  from queue import Queue
 
11
 
12
  from pydantic import Field, field_validator, SecretStr
13
  from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -77,7 +77,7 @@ def setup_logger(name: str) -> logging.Logger:
77
  loki_username = os.getenv('LOKI_USERNAME')
78
  loki_password = os.getenv('LOKI_PASSWORD')
79
 
80
- if loki_url and loki_username and loki_password:
81
  try:
82
  # Create async queue for non-blocking logging
83
  log_queue = Queue(maxsize=1000) # Buffer up to 1000 log messages
@@ -109,18 +109,8 @@ def setup_logger(name: str) -> logging.Logger:
109
  root_logger.addHandler(queue_handler)
110
 
111
  root_logger.info(f"Grafana Loki logging enabled: {loki_url} (tags: {loki_tags})")
112
- except Exception as e:
113
  root_logger.warning(f"Failed to setup Grafana Loki logging: {e}")
114
- else:
115
- missing = []
116
- if not loki_url:
117
- missing.append("LOKI_URL")
118
- if not loki_username:
119
- missing.append("LOKI_USERNAME")
120
- if not loki_password:
121
- missing.append("LOKI_PASSWORD")
122
- if missing:
123
- root_logger.info(f"Loki logging disabled (missing: {', '.join(missing)})")
124
 
125
  root_logger.setLevel(log_level)
126
 
@@ -134,6 +124,9 @@ class Config(BaseSettings):
134
  """Central configuration class for ai-me application with Pydantic validation."""
135
 
136
  # Environment Variables (from .env) - Required
 
 
 
137
  openai_api_key: SecretStr = Field(...,
138
  description="OpenAI API key for tracing")
139
  groq_api_key: SecretStr = Field(...,
@@ -155,6 +148,9 @@ class Config(BaseSettings):
155
  temperature: float = Field(
156
  default=1.0,
157
  description="LLM temperature for sampling (0.0-2.0, default 1.0)")
 
 
 
158
  github_repos: Union[str, List[str]] = Field(
159
  default="",
160
  description="GitHub repos to load (format: owner/repo), comma-separated in .env")
@@ -195,10 +191,14 @@ class Config(BaseSettings):
195
  os.environ["TOKENIZERS_PARALLELISM"] = "false"
196
 
197
  # Initialize Groq client for LLM operations
 
 
 
 
198
  self.openai_client = AsyncOpenAI(
199
  base_url="https://api.groq.com/openai/v1",
200
  api_key=self.groq_api_key.get_secret_value(),
201
- default_query={"temperature": self.temperature}
202
  )
203
  set_default_openai_client(self.openai_client)
204
 
@@ -206,7 +206,7 @@ class Config(BaseSettings):
206
  logger.info("Setting tracing export API key for agents.")
207
  set_tracing_export_api_key(self.openai_api_key.get_secret_value())
208
 
209
- def _safe_repr(self) -> str:
210
  """Helper to generate string representation excluding sensitive fields."""
211
  lines = ["Config:"]
212
  for field_name in type(self).model_fields:
@@ -216,14 +216,14 @@ class Config(BaseSettings):
216
  lines.append(f" {field_name}: {display}")
217
  return "\n".join(lines)
218
 
219
- def __repr__(self) -> str:
220
  """Return string representation of Config with secrets hidden.
221
 
222
  DEBUG: Debug utility for logging/debugging configuration state.
223
  """
224
  return self._safe_repr()
225
 
226
- def __str__(self) -> str:
227
  """Return human-readable string representation of Config with secrets hidden.
228
 
229
  DEBUG: Debug utility for logging/debugging configuration state.
 
2
  Configuration management for ai-me application.
3
  Centralizes environment variables, API clients, and application defaults.
4
  """
 
5
  import logging
6
+ import os
7
  import socket
 
8
  from logging.handlers import QueueHandler, QueueListener
9
  from queue import Queue
10
+ from typing import Optional, List, Union
11
 
12
  from pydantic import Field, field_validator, SecretStr
13
  from pydantic_settings import BaseSettings, SettingsConfigDict
 
77
  loki_username = os.getenv('LOKI_USERNAME')
78
  loki_password = os.getenv('LOKI_PASSWORD')
79
 
80
+ if loki_url and loki_username and loki_password: # pragma: no cover
81
  try:
82
  # Create async queue for non-blocking logging
83
  log_queue = Queue(maxsize=1000) # Buffer up to 1000 log messages
 
109
  root_logger.addHandler(queue_handler)
110
 
111
  root_logger.info(f"Grafana Loki logging enabled: {loki_url} (tags: {loki_tags})")
112
+ except Exception as e: # pragma: no cover
113
  root_logger.warning(f"Failed to setup Grafana Loki logging: {e}")
 
 
 
 
 
 
 
 
 
 
114
 
115
  root_logger.setLevel(log_level)
116
 
 
124
  """Central configuration class for ai-me application with Pydantic validation."""
125
 
126
  # Environment Variables (from .env) - Required
127
+ # Note: These have no defaults, so they MUST be in .env or will raise ValidationError
128
+ # We don't provide defaults here because Pydantic will raise an error at runtime
129
+ # if they're missing from the environment, which is the intended behavior.
130
  openai_api_key: SecretStr = Field(...,
131
  description="OpenAI API key for tracing")
132
  groq_api_key: SecretStr = Field(...,
 
148
  temperature: float = Field(
149
  default=1.0,
150
  description="LLM temperature for sampling (0.0-2.0, default 1.0)")
151
+ seed: Optional[int] = Field(
152
+ default=None,
153
+ description="Random seed for deterministic outputs (optional, for testing)")
154
  github_repos: Union[str, List[str]] = Field(
155
  default="",
156
  description="GitHub repos to load (format: owner/repo), comma-separated in .env")
 
191
  os.environ["TOKENIZERS_PARALLELISM"] = "false"
192
 
193
  # Initialize Groq client for LLM operations
194
+ default_query = {"temperature": self.temperature}
195
+ if self.seed is not None:
196
+ default_query["seed"] = self.seed
197
+
198
  self.openai_client = AsyncOpenAI(
199
  base_url="https://api.groq.com/openai/v1",
200
  api_key=self.groq_api_key.get_secret_value(),
201
+ default_query=default_query
202
  )
203
  set_default_openai_client(self.openai_client)
204
 
 
206
  logger.info("Setting tracing export API key for agents.")
207
  set_tracing_export_api_key(self.openai_api_key.get_secret_value())
208
 
209
+ def _safe_repr(self) -> str: # pragma: no cover
210
  """Helper to generate string representation excluding sensitive fields."""
211
  lines = ["Config:"]
212
  for field_name in type(self).model_fields:
 
216
  lines.append(f" {field_name}: {display}")
217
  return "\n".join(lines)
218
 
219
+ def __repr__(self) -> str: # pragma: no cover
220
  """Return string representation of Config with secrets hidden.
221
 
222
  DEBUG: Debug utility for logging/debugging configuration state.
223
  """
224
  return self._safe_repr()
225
 
226
+ def __str__(self) -> str: # pragma: no cover
227
  """Return human-readable string representation of Config with secrets hidden.
228
 
229
  DEBUG: Debug utility for logging/debugging configuration state.
src/data.py CHANGED
@@ -3,7 +3,10 @@ Document loading, processing, and vectorstore management for ai-me application.
3
  from local directories and GitHub repositories, chunking, and creating ChromaDB vector stores.
4
  """
5
  import os
 
 
6
  from typing import List, Optional, Callable
 
7
  from pydantic import BaseModel, Field
8
  from langchain_community.document_loaders import (
9
  DirectoryLoader,
@@ -16,8 +19,7 @@ from langchain_huggingface import HuggingFaceEmbeddings
16
  from langchain_chroma import Chroma
17
  import chromadb
18
  from chromadb.config import Settings
19
- import shutil
20
- import re
21
  from config import setup_logger
22
 
23
  logger = setup_logger(__name__)
@@ -27,11 +29,17 @@ class DataManagerConfig(BaseModel):
27
 
28
  doc_load_local: List[str] = Field(
29
  default=["**/*.md"], description="Glob patterns for local docs (e.g., ['*.md'])")
30
- github_repos: List[str] = Field(
31
- default=[], description="List of GitHub repos (format: owner/repo)")
32
  doc_root: str = Field(
33
- default=os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "docs", "local-testing")) + "/",
34
- description="Root directory for local documents (development/testing only)")
 
 
 
 
 
 
 
 
35
  chunk_size: int = Field(
36
  default=2500, description="Character chunk size for splitting")
37
  chunk_overlap: int = Field(
@@ -49,33 +57,19 @@ class DataManager:
49
  parameters have sensible defaults and can be overridden as needed.
50
  """
51
 
52
- def __init__(self, config: Optional[DataManagerConfig] = None, **kwargs):
53
  """
54
  Initialize data manager with configuration.
55
 
56
  Implements FR-002 (Knowledge Retrieval).
57
 
58
  Args:
59
- config: Optional DataManagerConfig instance. If not provided, one will be created
60
- from kwargs. For backward compatibility, can also pass individual parameters.
61
- **kwargs: Individual config parameters (doc_load_local, github_repos, etc.)
62
- Used when config is not provided or to override config values.
63
  """
64
- if config is None:
65
- # Create config from kwargs for backward compatibility
66
- self.config = DataManagerConfig(**kwargs)
67
- else:
68
- # Use provided config, but allow kwargs to override
69
- if kwargs:
70
- # Merge provided config with overrides
71
- config_dict = config.model_dump()
72
- config_dict.update(kwargs)
73
- self.config = DataManagerConfig(**config_dict)
74
- else:
75
- self.config = config
76
 
77
  # Internal state
78
- self._vectorstore: Optional[Chroma] = None
79
  self._embeddings: Optional[HuggingFaceEmbeddings] = None
80
 
81
  def load_local_documents(self) -> List[Document]:
@@ -109,7 +103,7 @@ class DataManager:
109
  documents = loader.load()
110
  logger.info(f" Found {len(documents)} documents")
111
  all_documents.extend(documents)
112
- except Exception as e:
113
  logger.info(
114
  f" Error loading pattern {pattern}: {e}"
115
  f" - skipping this pattern"
@@ -119,8 +113,11 @@ class DataManager:
119
  logger.info(f"Loaded {len(all_documents)} total local documents.")
120
  return all_documents
121
 
122
- def load_github_documents(self, repos: List[str] = None,
123
- file_filter: Optional[Callable[[str], bool]] = None, cleanup_tmp: bool = True
 
 
 
124
  ) -> List[Document]:
125
  """
126
  Load documents from GitHub repositories.
@@ -129,28 +126,29 @@ class DataManager:
129
 
130
  Args:
131
  repos: List of repos (owner/repo format). Defaults to github_repos from init.
132
- file_filter: Optional filter function for files. Defaults to .md files.
 
133
  cleanup_tmp: If True, remove tmp directory before loading.
134
 
135
  Returns:
136
  List of loaded documents from all repos.
137
  """
138
- if repos is None:
139
- repos = self.config.github_repos
140
 
 
141
  if file_filter is None:
142
- def file_filter(fp: str) -> bool:
143
- """Filter function for GitHub document loading.
144
 
145
- Implements FR-002 (Knowledge Retrieval): Filters markdown files for document loading.
 
 
146
  """
147
- fp_lower = fp.lower()
148
  basename = os.path.basename(fp).lower()
149
- # TBD: Make this configurable once chunking logic is enhanced
150
- keep = (fp_lower.endswith(".md") and
151
- basename not in ["contributing.md", "code_of_conduct.md", "security.md",
152
- "readme.md"])
153
- return keep
154
 
155
  all_docs = []
156
  # Clean up tmp directory before loading
@@ -160,25 +158,42 @@ class DataManager:
160
  logger.info(f"Cleaning up existing tmp directory: {tmp_dir}")
161
  shutil.rmtree(tmp_dir)
162
 
163
- logger.info(f"Loading GitHub documents from {len(repos)} repos {repos}")
164
- for repo in repos:
 
 
165
  logger.info(f"Loading GitHub repo: {repo}")
166
  try:
 
 
167
  loader = GitLoader(
168
  clone_url=f"https://github.com/{repo}",
169
- repo_path=f"{tmp_dir}/{repo}",
170
- file_filter=file_filter,
171
  branch="main",
172
  )
173
- docs = loader.load()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
 
175
  # Add repo metadata to each document
176
  for doc in docs:
177
  doc.metadata["github_repo"] = repo
178
 
179
  logger.info(f" Loaded {len(docs)} documents from {repo}")
180
- all_docs.extend(docs)
181
- except Exception as e:
182
  logger.info(f" Error loading repo {repo}: {e} - skipping")
183
  continue
184
 
@@ -237,6 +252,7 @@ class DataManager:
237
  strip_headers=False)
238
 
239
  all_chunks = []
 
240
  for doc in documents:
241
  # Split by headers first - this returns Documents with header metadata
242
  header_chunks = header_splitter.split_text(doc.page_content)
@@ -245,6 +261,8 @@ class DataManager:
245
  for chunk in header_chunks:
246
  # Create new Document with combined metadata
247
  merged_metadata = {**doc.metadata, **chunk.metadata}
 
 
248
  new_doc = Document(
249
  page_content=chunk.page_content,
250
  metadata=merged_metadata,
@@ -255,10 +273,14 @@ class DataManager:
255
  size_splitter = MarkdownTextSplitter(chunk_size=self.config.chunk_size)
256
  final_chunks = size_splitter.split_documents(all_chunks)
257
 
 
 
 
 
258
  logger.info(f"Created {len(final_chunks)} chunks")
259
  return final_chunks
260
 
261
- def load_and_process_all(self, github_repos: List[str] = None) -> List[Document]:
262
  """
263
  Load, process, and chunk all documents. Automatically loads local documents
264
  if doc_load_local is set, and GitHub documents if github_repos (or
@@ -279,10 +301,9 @@ class DataManager:
279
  if self.config.doc_load_local:
280
  all_docs.extend(self.load_local_documents())
281
 
282
- # Load GitHub documents if repos are configured
283
- repos_to_load = github_repos if github_repos is not None else self.config.github_repos
284
- if repos_to_load:
285
- all_docs.extend(self.load_github_documents(repos=repos_to_load))
286
 
287
  processed_docs = self.process_documents(all_docs)
288
  chunks = self.chunk_documents(processed_docs)
@@ -322,11 +343,11 @@ class DataManager:
322
  chroma_client = chromadb.EphemeralClient(Settings(anonymized_telemetry=False))
323
 
324
  # Drop existing collection if requested
325
- if reset:
326
  try:
327
  chroma_client.delete_collection(self.config.db_name)
328
  logger.info(f"Dropped existing collection: {self.config.db_name}")
329
- except Exception:
330
  pass # Collection doesn't exist yet
331
 
332
  logger.info(f"Creating vectorstore with {len(chunks)} chunks...")
@@ -340,11 +361,11 @@ class DataManager:
340
  count = vectorstore._collection.count()
341
  logger.info(f"Vectorstore created with {count} documents")
342
 
343
- self._vectorstore = vectorstore
344
  return vectorstore
345
 
346
  def setup_vectorstore(
347
- self, github_repos: List[str] = None, reset: bool = True
348
  ) -> Chroma:
349
  """
350
  Complete pipeline: load, process, chunk, and create vectorstore. Automatically
@@ -365,14 +386,20 @@ class DataManager:
365
  chunks = self.load_and_process_all(github_repos=github_repos)
366
  return self.create_vectorstore(chunks, reset=reset)
367
 
368
- def show_docs_for_file(self, filename: str):
369
  """
370
  Retrieve and print chunks from the vectorstore whose metadata['file_path'] ends with the
371
  given filename. Returns a list of (doc_id, metadata, document).
372
 
373
  DEBUG TOOL: Utility/debugging function - no corresponding FR/NFR.
374
  """
375
- all_docs = self._vectorstore.get()
 
 
 
 
 
 
376
  logger.info(f"Searching for chunks from file: {filename}")
377
 
378
  ids = all_docs.get("ids", [])
@@ -393,8 +420,5 @@ class DataManager:
393
  logger.info("=" * 100)
394
  logger.info(content)
395
  logger.info("")
396
-
397
- @property
398
- def vectorstore(self) -> Optional[Chroma]:
399
- """Get the current vectorstore instance."""
400
- return self._vectorstore
 
3
  from local directories and GitHub repositories, chunking, and creating ChromaDB vector stores.
4
  """
5
  import os
6
+ import re
7
+ import shutil
8
  from typing import List, Optional, Callable
9
+
10
  from pydantic import BaseModel, Field
11
  from langchain_community.document_loaders import (
12
  DirectoryLoader,
 
19
  from langchain_chroma import Chroma
20
  import chromadb
21
  from chromadb.config import Settings
22
+
 
23
  from config import setup_logger
24
 
25
  logger = setup_logger(__name__)
 
29
 
30
  doc_load_local: List[str] = Field(
31
  default=["**/*.md"], description="Glob patterns for local docs (e.g., ['*.md'])")
 
 
32
  doc_root: str = Field(
33
+ default=(
34
+ os.path.abspath(
35
+ os.path.join(
36
+ os.path.dirname(__file__), "..", "docs", "local-testing"
37
+ )
38
+ )
39
+ + "/"
40
+ ),
41
+ description="Root directory for local documents (development/testing only)"
42
+ )
43
  chunk_size: int = Field(
44
  default=2500, description="Character chunk size for splitting")
45
  chunk_overlap: int = Field(
 
57
  parameters have sensible defaults and can be overridden as needed.
58
  """
59
 
60
+ def __init__(self, config: DataManagerConfig):
61
  """
62
  Initialize data manager with configuration.
63
 
64
  Implements FR-002 (Knowledge Retrieval).
65
 
66
  Args:
67
+ config: DataManagerConfig instance with all settings
 
 
 
68
  """
69
+ self.config = config
 
 
 
 
 
 
 
 
 
 
 
70
 
71
  # Internal state
72
+ self.vectorstore: Optional[Chroma] = None
73
  self._embeddings: Optional[HuggingFaceEmbeddings] = None
74
 
75
  def load_local_documents(self) -> List[Document]:
 
103
  documents = loader.load()
104
  logger.info(f" Found {len(documents)} documents")
105
  all_documents.extend(documents)
106
+ except Exception as e: # pragma: no cover
107
  logger.info(
108
  f" Error loading pattern {pattern}: {e}"
109
  f" - skipping this pattern"
 
113
  logger.info(f"Loaded {len(all_documents)} total local documents.")
114
  return all_documents
115
 
116
+ def _load_github_documents(
117
+ self,
118
+ repos: Optional[List[str]] = None,
119
+ file_filter: Optional[Callable[[str], bool]] = None,
120
+ cleanup_tmp: bool = True
121
  ) -> List[Document]:
122
  """
123
  Load documents from GitHub repositories.
 
126
 
127
  Args:
128
  repos: List of repos (owner/repo format). Defaults to github_repos from init.
129
+ file_filter: Optional filter function for files. If None, uses default filter
130
+ excluding README, CONTRIBUTING, CODE_OF_CONDUCT, and SECURITY files.
131
  cleanup_tmp: If True, remove tmp directory before loading.
132
 
133
  Returns:
134
  List of loaded documents from all repos.
135
  """
 
 
136
 
137
+ # Default filter excludes common documentation files that degrade RAG quality
138
  if file_filter is None:
139
+ def default_file_filter(fp: str) -> bool:
140
+ """Default filter excludes contributing docs to preserve RAG quality.
141
 
142
+ Implements FR-002 (Knowledge Retrieval): Filters out common boilerplate
143
+ files (README, CONTRIBUTING, etc.) that aren't representative of
144
+ personified agent knowledge.
145
  """
 
146
  basename = os.path.basename(fp).lower()
147
+ # Exclude common boilerplate that doesn't represent agent's knowledge
148
+ excluded = {"readme.md", "contributing.md", "code_of_conduct.md",
149
+ "security.md"}
150
+ return basename not in excluded
151
+ file_filter = default_file_filter
152
 
153
  all_docs = []
154
  # Clean up tmp directory before loading
 
158
  logger.info(f"Cleaning up existing tmp directory: {tmp_dir}")
159
  shutil.rmtree(tmp_dir)
160
 
161
+ # Use provided repos or default to empty list if none specified
162
+ repos_to_load = repos if repos is not None else []
163
+ logger.info(f"Loading GitHub documents from {len(repos_to_load)} repos {repos_to_load}")
164
+ for repo in repos_to_load:
165
  logger.info(f"Loading GitHub repo: {repo}")
166
  try:
167
+ # Clone repo using GitLoader (even though it doesn't load files)
168
+ repo_path = f"{tmp_dir}/{repo}"
169
  loader = GitLoader(
170
  clone_url=f"https://github.com/{repo}",
171
+ repo_path=repo_path,
 
172
  branch="main",
173
  )
174
+ # GitLoader.load() doesn't return files, but it clones the repo
175
+ # so we use DirectoryLoader to actually load the markdown files
176
+ loader.load()
177
+
178
+ # Now use DirectoryLoader to load markdown files from the cloned repo
179
+ directory_loader = DirectoryLoader(
180
+ repo_path,
181
+ glob="**/*.md",
182
+ loader_cls=TextLoader,
183
+ loader_kwargs={'encoding': 'utf-8'}
184
+ )
185
+ docs = directory_loader.load()
186
+
187
+ # Apply filter (default or custom) to exclude irrelevant files
188
+ docs = [doc for doc in docs if file_filter(doc.metadata['source'])]
189
 
190
  # Add repo metadata to each document
191
  for doc in docs:
192
  doc.metadata["github_repo"] = repo
193
 
194
  logger.info(f" Loaded {len(docs)} documents from {repo}")
195
+ all_docs.extend(docs)
196
+ except Exception as e: # pragma: no cover
197
  logger.info(f" Error loading repo {repo}: {e} - skipping")
198
  continue
199
 
 
252
  strip_headers=False)
253
 
254
  all_chunks = []
255
+ chunk_index = 0 # Track chunk number across all documents
256
  for doc in documents:
257
  # Split by headers first - this returns Documents with header metadata
258
  header_chunks = header_splitter.split_text(doc.page_content)
 
261
  for chunk in header_chunks:
262
  # Create new Document with combined metadata
263
  merged_metadata = {**doc.metadata, **chunk.metadata}
264
+ merged_metadata['chunk_index'] = chunk_index # Add global chunk index
265
+ chunk_index += 1
266
  new_doc = Document(
267
  page_content=chunk.page_content,
268
  metadata=merged_metadata,
 
273
  size_splitter = MarkdownTextSplitter(chunk_size=self.config.chunk_size)
274
  final_chunks = size_splitter.split_documents(all_chunks)
275
 
276
+ # Re-index after size splitting to maintain sequential chunk indices
277
+ for i, chunk in enumerate(final_chunks):
278
+ chunk.metadata['chunk_index'] = i
279
+
280
  logger.info(f"Created {len(final_chunks)} chunks")
281
  return final_chunks
282
 
283
+ def load_and_process_all(self, github_repos: Optional[List[str]] = None) -> List[Document]:
284
  """
285
  Load, process, and chunk all documents. Automatically loads local documents
286
  if doc_load_local is set, and GitHub documents if github_repos (or
 
301
  if self.config.doc_load_local:
302
  all_docs.extend(self.load_local_documents())
303
 
304
+ # Load GitHub documents if repos are provided (github_repos must come from caller)
305
+ if github_repos:
306
+ all_docs.extend(self._load_github_documents(repos=github_repos))
 
307
 
308
  processed_docs = self.process_documents(all_docs)
309
  chunks = self.chunk_documents(processed_docs)
 
343
  chroma_client = chromadb.EphemeralClient(Settings(anonymized_telemetry=False))
344
 
345
  # Drop existing collection if requested
346
+ if reset: # pragma: no cover
347
  try:
348
  chroma_client.delete_collection(self.config.db_name)
349
  logger.info(f"Dropped existing collection: {self.config.db_name}")
350
+ except Exception: # pragma: no cover
351
  pass # Collection doesn't exist yet
352
 
353
  logger.info(f"Creating vectorstore with {len(chunks)} chunks...")
 
361
  count = vectorstore._collection.count()
362
  logger.info(f"Vectorstore created with {count} documents")
363
 
364
+ self.vectorstore = vectorstore
365
  return vectorstore
366
 
367
  def setup_vectorstore(
368
+ self, github_repos: Optional[List[str]] = None, reset: bool = True
369
  ) -> Chroma:
370
  """
371
  Complete pipeline: load, process, chunk, and create vectorstore. Automatically
 
386
  chunks = self.load_and_process_all(github_repos=github_repos)
387
  return self.create_vectorstore(chunks, reset=reset)
388
 
389
+ def show_docs_for_file(self, filename: str): # pragma: no cover
390
  """
391
  Retrieve and print chunks from the vectorstore whose metadata['file_path'] ends with the
392
  given filename. Returns a list of (doc_id, metadata, document).
393
 
394
  DEBUG TOOL: Utility/debugging function - no corresponding FR/NFR.
395
  """
396
+ if self.vectorstore is None:
397
+ logger.warning(
398
+ "Vectorstore not initialized. Call setup_vectorstore() first."
399
+ )
400
+ return []
401
+
402
+ all_docs = self.vectorstore.get()
403
  logger.info(f"Searching for chunks from file: {filename}")
404
 
405
  ids = all_docs.get("ids", [])
 
420
  logger.info("=" * 100)
421
  logger.info(content)
422
  logger.info("")
423
+
424
+ return matched
 
 
 
src/notebooks/experiments.ipynb CHANGED
@@ -16,10 +16,7 @@
16
  "outputs": [],
17
  "source": [
18
  "# Setup configuration\n",
19
- "import sys\n",
20
- "sys.path.append('/Users/benyoung/projects/ai-me')\n",
21
- "\n",
22
- "from src.config import Config\n",
23
  "from IPython.display import Markdown\n",
24
  "from agents import trace, Runner\n",
25
  "\n",
@@ -44,9 +41,9 @@
44
  "outputs": [],
45
  "source": [
46
  "from importlib import reload\n",
47
- "import src.data as _data_module\n",
48
  "reload(_data_module)\n",
49
- "from src.data import DataManager, DataManagerConfig\n",
50
  "\n",
51
  "\n",
52
  "# Use consolidated data manager\n",
@@ -85,7 +82,7 @@
85
  "metadata": {},
86
  "outputs": [],
87
  "source": [
88
- "from src.agent import AIMeAgent\n",
89
  "\n",
90
  "# Initialize agent config with vectorstore\n",
91
  "agent_config = AIMeAgent(\n",
@@ -95,7 +92,7 @@
95
  " github_token=config.github_token\n",
96
  ")\n",
97
  "\n",
98
- "ai_me = await agent_config.create_ai_me_agent()\n"
99
  ]
100
  },
101
  {
@@ -275,9 +272,9 @@
275
  "outputs": [],
276
  "source": [
277
  "# Reload agent module to pick up latest changes\n",
278
- "import src.agent as _agent_module\n",
279
  "reload(_agent_module)\n",
280
- "from src.agent import AIMeAgent\n",
281
  "\n",
282
  "# Recreate agent config with updated module\n",
283
  "agent_config = AIMeAgent(\n",
 
16
  "outputs": [],
17
  "source": [
18
  "# Setup configuration\n",
19
+ "from config import Config\n",
 
 
 
20
  "from IPython.display import Markdown\n",
21
  "from agents import trace, Runner\n",
22
  "\n",
 
41
  "outputs": [],
42
  "source": [
43
  "from importlib import reload\n",
44
+ "import data as _data_module\n",
45
  "reload(_data_module)\n",
46
+ "from data import DataManager, DataManagerConfig\n",
47
  "\n",
48
  "\n",
49
  "# Use consolidated data manager\n",
 
82
  "metadata": {},
83
  "outputs": [],
84
  "source": [
85
+ "from agent import AIMeAgent\n",
86
  "\n",
87
  "# Initialize agent config with vectorstore\n",
88
  "agent_config = AIMeAgent(\n",
 
92
  " github_token=config.github_token\n",
93
  ")\n",
94
  "\n",
95
+ "ai_me = await agent_config.create_ai_me_agent()"
96
  ]
97
  },
98
  {
 
272
  "outputs": [],
273
  "source": [
274
  "# Reload agent module to pick up latest changes\n",
275
+ "import agent as _agent_module\n",
276
  "reload(_agent_module)\n",
277
+ "from agent import AIMeAgent\n",
278
  "\n",
279
  "# Recreate agent config with updated module\n",
280
  "agent_config = AIMeAgent(\n",
tests/data/README.md ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Test Data Directory
2
+
3
+ This directory contains controlled test data for RAG (Retrieval Augmented Generation) testing in the ai-me project.
4
+
5
+ ## Purpose
6
+
7
+ These markdown files provide known content for deterministic testing of:
8
+ 1. Document loading and chunking (from local files)
9
+ 2. Vector embeddings and storage (ChromaDB)
10
+ 3. Retrieval quality (similarity search)
11
+ 4. Agent response accuracy (RAG output validation)
12
+
13
+ ## Files
14
+
15
+ | File | Purpose | Key Content | Used By Tests |
16
+ |------|---------|-------------|---------------|
17
+ | **rear_info.md** | ReaR disaster recovery info | Project ID: IT-245 | test_rear_knowledge_contains_it245 |
18
+ | **projects.md** | Project listings | IT-245, IT-300, APP-101, DATA-500 | General project queries |
19
+ | **team_info.md** | Team structure (fictional) | Alice, Bob, Carol + departments | Person/team queries |
20
+ | **faq.md** | FAQ with tech stack, workflows | IT-245 references, dev processes | General knowledge queries |
21
+ | **README.md** | This documentation | Test data guide | - |
22
+
23
+ ## Statistics
24
+
25
+ - **Total Files**: 5 markdown files
26
+ - **Total Chunks**: ~38 (after splitting with CharacterTextSplitter)
27
+ - **Chunk Size**: 2500 characters (default)
28
+ - **Chunk Overlap**: 0 characters (default)
29
+ - **Embedding Model**: sentence-transformers/all-MiniLM-L6-v2
30
+
31
+ ## Usage in Tests
32
+
33
+ The test suite (`tests/integration/spec-001.py`) automatically uses this directory:
34
+
35
+ ```python
36
+ # Configuration in tests/integration/spec-001.py
37
+ os.environ["GITHUB_REPOS"] = "" # Disable GitHub loading
38
+ test_data_dir = os.path.join(project_root, "tests", "data")
39
+
40
+ # DataManager initialization with required config
41
+ config = DataManagerConfig(
42
+ doc_load_local=["**/*.md"],
43
+ github_repos=[],
44
+ doc_root=test_data_dir # Points to this directory
45
+ )
46
+ data_manager = DataManager(config=config)
47
+ ```
48
+
49
+ ## Test Cases
50
+
51
+ ### ✅ Test 1: ReaR Knowledge (IT-245)
52
+ **Query**: "What do you know about ReaR?"
53
+
54
+ **Source**: `rear_info.md`
55
+
56
+ **Validates**:
57
+ - Document retrieval works correctly
58
+ - Agent finds and extracts specific project information
59
+ - Response contains "IT-245" identifier
60
+
61
+ **Expected Output**: Response mentions ReaR, disaster recovery, and IT-245 project.
62
+
63
+ ### ⏭️ Test 2: GitHub Commits (Skipped)
64
+ **Note**: Requires MCP servers (disabled for test speed).
65
+
66
+ ### ✅ Test 3: Unknown Person (Negative Test)
67
+ **Query**: "Who is slartibartfast?"
68
+
69
+ **Source**: None (intentionally missing)
70
+
71
+ **Validates**:
72
+ - Agent handles missing information gracefully
73
+ - No hallucination or fabricated responses
74
+ - Proper "don't have information" response
75
+
76
+ **Expected Output**: Response contains negative indicators like "don't have", "no information", etc.
77
+
78
+ ## Benefits vs. Loading from GitHub
79
+
80
+ | Aspect | Test Data Directory | GitHub Loading |
81
+ |--------|-------------------|----------------|
82
+ | **Speed** | ~10 seconds total | Minutes per test run |
83
+ | **Network** | None required | API calls needed |
84
+ | **Determinism** | Fully controlled | May change over time |
85
+ | **Setup** | Already included | Requires GitHub token |
86
+ | **Isolation** | Completely isolated | External dependency |
87
+
88
+ ## Key Implementation Details
89
+
90
+ ### Local Document Metadata
91
+
92
+ Unlike GitHub documents, local documents have simplified metadata:
93
+
94
+ ```python
95
+ # GitHub documents have:
96
+ doc.metadata['github_repo'] = 'owner/repo'
97
+ doc.metadata['file_path'] = 'path/to/file.md'
98
+
99
+ # Local documents have:
100
+ doc.metadata['source'] = '/full/path/to/file.md'
101
+ # NO github_repo field
102
+ ```
103
+
104
+ The `get_local_info` tool in `src/agent.py` was updated to handle both cases.
105
+
106
+ ### Unicode Handling
107
+
108
+ Test assertions handle Unicode variants:
109
+
110
+ - **Hyphens**: `IT-245` (regular) vs `IT‑245` (non-breaking)
111
+ - **Apostrophes**: `don't` (regular) vs `don't` (smart quote)
112
+ - **Spaces**: Regular space vs non-breaking space (`\u00a0`)
113
+
114
+ ## Adding New Test Data
115
+
116
+ To add new test content:
117
+
118
+ 1. **Create markdown file** in this directory:
119
+ ```bash
120
+ touch tests/data/my_topic.md
121
+ # Add relevant content with known facts
122
+ ```
123
+
124
+ 2. **Add test case** in `tests/integration/spec-001.py`:
125
+ ```python
126
+ @pytest.mark.asyncio
127
+ async def test_my_topic_knowledge(ai_me_agent):
128
+ query = "What do you know about [topic]?"
129
+ result = await Runner.run(ai_me_agent, query, max_turns=30)
130
+ assert "[expected_content]" in result.final_output
131
+ ```
132
+
133
+ 3. **Document** in this README
134
+
135
+ 4. **Verify** chunks created:
136
+ ```bash
137
+ uv run pytest tests/ -v -s | grep "Created.*chunks"
138
+ ```
139
+
140
+ ## Maintenance Guidelines
141
+
142
+ ### What TO Include
143
+
144
+ ✅ Fictional but realistic data
145
+ ✅ Specific identifiers for testing (e.g., IT-245)
146
+ ✅ Structured markdown with clear headings
147
+ ✅ Cross-references between documents
148
+ ✅ Both positive and negative test cases
149
+
150
+ ### What NOT to Include
151
+
152
+ ❌ Real personal information or PII
153
+ ❌ Sensitive company data
154
+ ❌ Large binary files or images
155
+ ❌ External dependencies
156
+ ❌ Dynamic/time-sensitive content
157
+
158
+ ## Troubleshooting
159
+
160
+ ### "Vectorstore setup complete with 0 documents"
161
+ **Cause**: Files not loading from tests/data directory
162
+
163
+ **Fix**: Verify `doc_root` parameter and file patterns
164
+
165
+ ### "Expected 'IT-245' in response but got..."
166
+ **Cause**: LLM used Unicode non-breaking hyphen
167
+
168
+ **Fix**: Test already handles both variants, check for other formatting
169
+
170
+ ### Test execution is slow (> 30 seconds)
171
+ **Cause**: May be loading from GitHub instead of tests/data
172
+
173
+ **Fix**: Verify `GITHUB_REPOS=""` in test environment setup
174
+
175
+ ## Performance Benchmarks
176
+
177
+ Measured on M1 MacBook Pro:
178
+
179
+ - **Vectorstore Setup**: 2-3 seconds (includes embedding model loading)
180
+ - **Test 1 (ReaR)**: 3-4 seconds (includes LLM calls)
181
+ - **Test 3 (Unknown)**: 3-4 seconds
182
+ - **Total Runtime**: ~10 seconds for all passing tests
183
+
184
+ Compare to production setup with GitHub repos: 2-5 minutes
185
+
186
+ ## Future Enhancements
187
+
188
+ - [ ] Add more domain-specific test documents
189
+ - [ ] Create test cases for multi-document synthesis
190
+ - [ ] Add edge cases (empty files, malformed markdown)
191
+ - [ ] Performance regression tests
192
+ - [ ] Quality metrics (retrieval precision/recall)
193
+
194
+ For more details, see `/TESTING.md` in the project root.
tests/data/projects.md ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Active Projects
2
+
3
+ ## Infrastructure Projects
4
+
5
+ ### IT-245: Disaster Recovery
6
+ **Status**: In Progress
7
+ **Owner**: Infrastructure Team
8
+ **Timeline**: Q4 2024 - Q1 2025
9
+
10
+ Implementation of Relax-and-Recover (ReaR) backup solution across production servers. This provides automated disaster recovery capabilities with bare metal restoration.
11
+
12
+ **Key Milestones**:
13
+ - ✅ Proof of concept completed
14
+ - ✅ Test environment deployment
15
+ - 🔄 Production rollout (in progress)
16
+ - ⏳ Documentation and training
17
+
18
+ ### IT-300: Network Segmentation
19
+ **Status**: Planning
20
+ **Owner**: Security Team
21
+ **Timeline**: Q1 2025
22
+
23
+ Network redesign to implement zero-trust architecture with micro-segmentation.
24
+
25
+ ## Application Projects
26
+
27
+ ### APP-101: Customer Portal v2
28
+ **Status**: Active Development
29
+ **Owner**: Frontend Team
30
+ **Timeline**: Q4 2024 - Q2 2025
31
+
32
+ Complete redesign of customer-facing portal with modern React architecture.
33
+
34
+ ### APP-150: API Gateway Upgrade
35
+ **Status**: In Progress
36
+ **Owner**: Backend Team
37
+ **Timeline**: Q4 2024
38
+
39
+ Migrating from legacy API gateway to Kong with enhanced security and monitoring.
40
+
41
+ ## Data Projects
42
+
43
+ ### DATA-500: Analytics Platform
44
+ **Status**: Active Development
45
+ **Owner**: Data Engineering Team
46
+ **Timeline**: Q3 2024 - Q2 2025
47
+
48
+ Building modern data warehouse on Snowflake with real-time analytics capabilities.
49
+
50
+ ### DATA-510: ML Pipeline
51
+ **Status**: Planning
52
+ **Owner**: Data Science Team
53
+ **Timeline**: Q1 2025 - Q3 2025
54
+
55
+ Automated machine learning pipeline for predictive analytics and recommendation systems.
56
+
57
+ ## Completed Projects
58
+
59
+ - **IT-200**: VMware to KVM migration (Completed Q3 2024)
60
+ - **APP-90**: Mobile app release (Completed Q2 2024)
61
+ - **SEC-400**: SOC 2 compliance (Completed Q1 2024)
tests/data/team.md ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Team Information
2
+
3
+ ## Operation Agenetic Me
4
+
5
+ ### Engineers
6
+
7
+ - **Ben Young** - Lead Software Engineer
8
+ - Specializes in backend systems
9
+ - Working on microservices architecture
10
+ - Programming languages: Python, Go, TypeScript, Rust, SQL
11
+ - Expertise in: async programming, distributed systems, cloud infrastructure
12
+ - Contact: ben@example.com
13
+
14
+ - **Bob Smith** - Frontend Developer
15
+ - React and TypeScript expert
16
+ - UI/UX design background
17
+ - Contact: bob@example.com
18
+
19
+ ### Product Managers
20
+
21
+ - **Carol Williams** - Product Owner
22
+ - Write requirements
23
+ - Educate team on user profiles
24
+ - Contact: carol@example.com
25
+
26
+ ### Recent Projects
27
+
28
+ 1. **Project Phoenix** - Cloud migration initiative
29
+ 2. **Project Titan** - New customer portal
30
+ 3. **IT-245** - ReaR disaster recovery implementation
31
+
32
+ ## Department Structure
33
+
34
+ - Engineering Director: David Chen
35
+ - Product Manager: Emma Davis
36
+ - QA Lead: Frank Miller
37
+
38
+ ## Office Locations
39
+
40
+ - San Francisco HQ
41
+ - Austin Remote Office
42
+ - London European Hub
43
+
44
+ ## Team Events
45
+
46
+ - Weekly stand-ups: Monday 9 AM PST
47
+ - Sprint planning: Every other Wednesday
48
+ - Team retrospectives: Last Friday of the month
49
+ - Quarterly all-hands: First week of Q1, Q2, Q3, Q4
tests/integration/spec-001.py ADDED
@@ -0,0 +1,507 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Integration tests for ai-me agent.
3
+ Tests the complete setup including vectorstore, agent configuration, and agent responses.
4
+ """
5
+ import pytest
6
+ import pytest_asyncio
7
+ import re
8
+ import sys
9
+ import os
10
+ import logging
11
+ from datetime import datetime
12
+ from unittest.mock import AsyncMock, patch
13
+
14
+ # Something about these tests makes me feel yucky. Big, brittle, and slow. BBS?
15
+ # In the future we should run inference locally with docker-compose models.
16
+
17
+ # Set temperature and seed for deterministic test results
18
+ os.environ["TEMPERATURE"] = "0"
19
+ os.environ["SEED"] = "42"
20
+
21
+ # Point our RAG to the tests/data directory
22
+ project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
23
+ test_data_dir = os.path.join(project_root, "tests", "data")
24
+ os.environ["DOC_ROOT"] = test_data_dir
25
+ os.environ["LOCAL_DOCS"] = "**/*.md"
26
+
27
+ from config import setup_logger, Config
28
+ from agent import AIMeAgent
29
+ from data import DataManager, DataManagerConfig
30
+
31
+ logger = setup_logger(__name__)
32
+
33
+ # ============================================================================
34
+ # SHARED CACHING - Initialize on first use, then reuse
35
+ # ============================================================================
36
+
37
+ _config = None
38
+ _vectorstore = None
39
+ _data_manager = None
40
+
41
+
42
+ def _get_shared_config():
43
+ """Lazy initialization of shared config."""
44
+ global _config
45
+ if _config is None:
46
+ _config = Config() # type: ignore
47
+ logger.info(f"Initialized shared config: {_config.bot_full_name}")
48
+ return _config
49
+
50
+
51
+ def _get_shared_vectorstore():
52
+ """Lazy initialization of shared vectorstore."""
53
+ global _vectorstore, _data_manager
54
+ if _vectorstore is None:
55
+ logger.info("Initializing shared vectorstore (first test)...")
56
+ test_data_dir = os.path.join(project_root, "tests", "data")
57
+ _data_config = DataManagerConfig(
58
+ doc_root=test_data_dir
59
+ )
60
+ _data_manager = DataManager(config=_data_config)
61
+ _vectorstore = _data_manager.setup_vectorstore()
62
+ logger.info(f"Shared vectorstore ready: {_vectorstore._collection.count()} documents")
63
+ return _vectorstore
64
+
65
+
66
+ @pytest_asyncio.fixture(scope="function")
67
+ async def ai_me_agent():
68
+ """
69
+ Setup fixture for ai-me agent with vectorstore and MCP servers.
70
+
71
+ CRITICAL: Function-scoped fixture prevents hanging/blocking issues.
72
+ Each test gets its own agent instance with proper cleanup.
73
+
74
+ Reuses shared config and vectorstore (lazy-initialized on first use).
75
+
76
+ This fixture:
77
+ - Reuses shared config and vectorstore
78
+ - Creates agent WITH real subprocess MCP servers (GitHub, Time, Memory)
79
+ - Yields agent for test
80
+ - Cleans up MCP servers after test completes
81
+ """
82
+ config = _get_shared_config()
83
+ vectorstore = _get_shared_vectorstore()
84
+
85
+ # Initialize agent config with shared vectorstore
86
+ aime_agent = AIMeAgent(
87
+ bot_full_name=config.bot_full_name,
88
+ model=config.model,
89
+ vectorstore=vectorstore,
90
+ github_token=config.github_token,
91
+ session_id="test-session"
92
+ )
93
+
94
+ # Create the agent WITH MCP servers enabled
95
+ logger.info("Creating ai-me agent with MCP servers...")
96
+ assert aime_agent.session_id is not None, "session_id should be set"
97
+ await aime_agent.create_ai_me_agent(
98
+ mcp_params=[
99
+ aime_agent.mcp_github_params,
100
+ aime_agent.mcp_time_params,
101
+ aime_agent.get_mcp_memory_params(aime_agent.session_id),
102
+ ]
103
+ )
104
+ logger.info("Agent created successfully with MCP servers")
105
+ logger.info(f"Temperature set to {config.temperature}")
106
+ logger.info(f"Seed set to {config.seed}")
107
+
108
+ # Yield the agent for the test
109
+ yield aime_agent
110
+
111
+ # CRITICAL: Cleanup after test completes to prevent hanging
112
+ logger.info("Cleaning up MCP servers after test...")
113
+ await aime_agent.cleanup()
114
+ logger.info("Cleanup complete")
115
+
116
+
117
+ @pytest.mark.asyncio
118
+ async def test_github_documents_load():
119
+ """Tests FR-002: GitHub document loading with source metadata."""
120
+ config = Config() # type: ignore
121
+
122
+ # Load GitHub documents directly
123
+ github_config = DataManagerConfig(
124
+ doc_load_local=[]
125
+ )
126
+ dm = DataManager(config=github_config)
127
+ vs = dm.setup_vectorstore(github_repos=["byoung/ai-me"])
128
+
129
+ agent = AIMeAgent(
130
+ bot_full_name=config.bot_full_name,
131
+ model=config.model,
132
+ vectorstore=vs,
133
+ github_token=config.github_token,
134
+ session_id="test-session"
135
+ )
136
+ await agent.create_ai_me_agent()
137
+
138
+ response = await agent.run("Do you have python experience?")
139
+
140
+ assert "yes" in response.lower(), (
141
+ f"yes' in response but got: {response}"
142
+ )
143
+
144
+
145
+ @pytest.mark.asyncio
146
+ async def test_rear_knowledge_contains_it245(ai_me_agent):
147
+ """Tests REQ-001: Knowledge base retrieval of personal documentation."""
148
+ response = await ai_me_agent.run("What is IT-245?")
149
+
150
+ assert "IT-245" in response or "It-245" in response or "it-245" in response
151
+ logger.info("✓ IT-245 found in response")
152
+
153
+
154
+ @pytest.mark.asyncio
155
+ async def test_github_commits_contains_shas(ai_me_agent):
156
+ """Tests REQ-002: MCP GitHub integration - retrieve commit history."""
157
+ response = await ai_me_agent.run("What are some recent commits I've made?")
158
+
159
+ assert response, "Response is empty"
160
+ assert len(response) > 10, "Response is too short"
161
+ logger.info("✓ Response contains commit information")
162
+
163
+ @pytest.mark.asyncio
164
+ async def test_unknown_person_contains_negative_response(ai_me_agent):
165
+ """Tests REQ-003: Graceful handling of out-of-scope requests."""
166
+ response = await ai_me_agent.run(
167
+ "Do you know Slartibartfast?" # Presumed unknown person
168
+ )
169
+
170
+ assert response, "Response is empty"
171
+ assert (
172
+ "don't know" in response.lower()
173
+ or "not familiar" in response.lower()
174
+ or "no information" in response.lower()
175
+ or "don't have any information" in response.lower()
176
+ ), f"Response doesn't indicate lack of knowledge: {response}"
177
+ logger.info(f"✓ Test passed - correctly handled out-of-scope query")
178
+
179
+
180
+ @pytest.mark.asyncio
181
+ async def test_carol_knowledge_contains_product(ai_me_agent):
182
+ """Tests FR-002, FR-003: Verify asking about Carol returns 'product'."""
183
+ response_raw = await ai_me_agent.run("Do you know Carol?")
184
+ response = response_raw.lower() # Convert to lowercase for matching
185
+
186
+ # Assert that 'product' appears in the response (Carol is Product Owner)
187
+ assert "product" in response, (
188
+ f"Expected 'product' in response but got: {response}"
189
+ )
190
+ logger.info("✓ Test passed: Response contains 'product'")
191
+
192
+
193
+ @pytest.mark.asyncio
194
+ async def test_mcp_time_server_returns_current_date(ai_me_agent):
195
+ """Tests FR-009, NFR-001: Verify that the MCP time server returns the current date."""
196
+ response = await ai_me_agent.run("What is today's date?")
197
+
198
+ # Check for current date in various formats (ISO or natural language)
199
+ now = datetime.now()
200
+ expected_date, current_year, current_month, current_day = (
201
+ now.strftime("%Y-%m-%d"),
202
+ str(now.year),
203
+ now.strftime("%B"),
204
+ str(now.day),
205
+ )
206
+
207
+ # Accept either ISO format or natural language date
208
+ has_date = (
209
+ expected_date in response
210
+ or (
211
+ current_year in response
212
+ and current_month in response
213
+ and current_day in response
214
+ )
215
+ )
216
+
217
+ assert has_date, (
218
+ f"Expected response to contain current date "
219
+ f"({expected_date} or {current_month} {current_day}, {current_year}) "
220
+ f"but got: {response}"
221
+ )
222
+ logger.info(f"✓ Test passed: Response contains current date")
223
+
224
+
225
+ @pytest.mark.asyncio
226
+ async def test_mcp_memory_server_remembers_favorite_color(ai_me_agent):
227
+ """Tests FR-013, NFR-002:
228
+ Verify that the MCP memory server persists information across interactions.
229
+ """
230
+ await ai_me_agent.run("My favorite color is chartreuse.")
231
+ response2 = await ai_me_agent.run("What's my favorite color?")
232
+
233
+ # Check that the agent remembers the color
234
+ assert "chartreuse" in response2.lower(), (
235
+ f"Expected agent to remember favorite color 'chartreuse' "
236
+ f"but got: {response2}"
237
+ )
238
+ msg = (
239
+ "✓ Test passed: Agent remembered favorite color 'chartreuse' "
240
+ "across interactions"
241
+ )
242
+ logger.info(msg)
243
+
244
+
245
+ @pytest.mark.asyncio
246
+ async def test_github_relative_links_converted_to_absolute_urls():
247
+ """Tests FR-004: Document processing converts relative GitHub links to absolute URLs.
248
+
249
+ Validates that when documents are loaded from GitHub with relative links
250
+ (e.g., /resume.md), they are rewritten to full GitHub URLs
251
+ (e.g., https://github.com/owner/repo/blob/main/resume.md).
252
+
253
+ This is a unit-level test of the DataManager.process_documents() method.
254
+ """
255
+ from langchain_core.documents import Document
256
+
257
+ sample_doc = Document(
258
+ page_content=(
259
+ "Check out [my resume](/resume.md) and "
260
+ "[projects](/projects.md) for more info."
261
+ ),
262
+ metadata={
263
+ "source": "github://byoung/ai-me/docs/about.md",
264
+ "github_repo": "byoung/ai-me"
265
+ }
266
+ )
267
+
268
+ # Verify metadata is set correctly before processing
269
+ assert sample_doc.metadata["github_repo"] == "byoung/ai-me", (
270
+ "Sample doc metadata should have github_repo"
271
+ )
272
+
273
+ data_config = DataManagerConfig()
274
+ data_manager = DataManager(config=data_config)
275
+ processed_docs = data_manager.process_documents([sample_doc])
276
+
277
+ assert len(processed_docs) == 1, "Expected 1 processed document"
278
+ processed_content = processed_docs[0].page_content
279
+
280
+ # Check that relative links have been converted to absolute GitHub URLs
281
+ assert "https://github.com/byoung/ai-me/blob/main/resume.md" in processed_content, (
282
+ f"Expected absolute GitHub URL for /resume.md in processed content, "
283
+ f"but got: {processed_content}"
284
+ )
285
+ assert "https://github.com/byoung/ai-me/blob/main/projects.md" in processed_content, (
286
+ f"Expected absolute GitHub URL for /projects.md in processed content, "
287
+ f"but got: {processed_content}"
288
+ )
289
+
290
+ logger.info("✓ Test passed: Relative GitHub links converted to absolute URLs")
291
+ logger.info(f" Original: [my resume](/resume.md)")
292
+ logger.info(f" Converted: [my resume](https://github.com/byoung/ai-me/blob/main/resume.md)")
293
+
294
+
295
+ @pytest.mark.asyncio
296
+ async def test_agent_responses_cite_sources(ai_me_agent):
297
+ """Tests FR-004, FR-011: Agent responses include source citations.
298
+
299
+ Validates that agent responses include proper source attribution,
300
+ which could be GitHub URLs, local paths, or explicit source references.
301
+ """
302
+ questions = [
303
+ "What do you know about ReaR?",
304
+ "Tell me about your experience in technology",
305
+ ]
306
+
307
+ for question in questions:
308
+ logger.info(f"\n{'='*60}\nSource citation test: {question}\n{'='*60}")
309
+
310
+ response = await ai_me_agent.run(question)
311
+
312
+ # Check that response includes some form of source attribution
313
+ # Could be: GitHub URL, local path, "Sources" section, etc.
314
+ has_source = (
315
+ "https://github.com/" in response or
316
+ ".md" in response or # Local markdown file reference
317
+ "source" in response.lower() or
318
+ "documentation" in response.lower()
319
+ )
320
+ assert has_source, (
321
+ f"Expected source attribution in response to '{question}' "
322
+ f"but found none. Response: {response}"
323
+ )
324
+
325
+ # Verify response is substantive (not just metadata)
326
+ min_length = 50
327
+ assert len(response) > min_length, (
328
+ f"Response to '{question}' was too short: {response}"
329
+ )
330
+
331
+ logger.info(f"✓ Source citation found for: {question[:40]}...")
332
+
333
+ logger.info("\n✓ Test passed: Agent responses cite sources (FR-004, FR-011)")
334
+
335
+
336
+ @pytest.mark.asyncio
337
+ async def test_user_story_2_multi_topic_consistency(ai_me_agent):
338
+ """
339
+ Tests FR-001, FR-003, FR-005, NFR-002: User Story 2 - Multi-Topic Consistency
340
+
341
+ Verify that the agent maintains consistent first-person perspective
342
+ across multiple conversation topics.
343
+
344
+ This tests that the agent:
345
+ - Uses first-person perspective (I, my, me) consistently
346
+ - Maintains professional tone across different topic switches
347
+ - Shows context awareness of different topics
348
+ - Remains in-character as the personified individual
349
+ """
350
+ # Ask 3 questions about different topics
351
+ topics = [
352
+ ("What is your background in technology?", "background|experience|technology"),
353
+ ("What programming languages are you skilled in?", "programming|language|skilled"),
354
+ ]
355
+
356
+ first_person_patterns = [
357
+ r"\bi\b", r"\bme\b", r"\bmy\b", r"\bmyself\b",
358
+ r"\bI['m]", r"\bI['ve]", r"\bI['ll]"
359
+ ]
360
+
361
+ for question, topic_keywords in topics:
362
+ logger.info(f"\n{'='*60}\nMulti-topic test question: {question}\n{'='*60}")
363
+
364
+ response = await ai_me_agent.run(question)
365
+ response_lower = response.lower()
366
+
367
+ # Check for first-person usage
368
+ first_person_found = any(
369
+ re.search(pattern, response, re.IGNORECASE)
370
+ for pattern in first_person_patterns
371
+ )
372
+ assert first_person_found, (
373
+ f"Expected first-person perspective in response to '{question}' "
374
+ f"but got: {response}"
375
+ )
376
+
377
+ # Verify response is substantive (not just "I don't know")
378
+ min_length = 50 # Substantive responses should be > 50 chars
379
+ assert len(response) > min_length, (
380
+ f"Response to '{question}' was too short (likely not substantive): {response}"
381
+ )
382
+
383
+ logger.info(f"✓ First-person perspective maintained for: {question[:40]}...")
384
+ logger.info(f" Response preview: {response[:100]}...")
385
+
386
+ logger.info("\n✓ Test passed: Consistent first-person perspective across 3+ topics")
387
+
388
+
389
+ @pytest.mark.asyncio
390
+ async def test_tool_failure_error_messages_are_friendly(caplog, ai_me_agent):
391
+ """
392
+ Tests FR-012, NFR-003: Error Message Quality (FR-012)
393
+
394
+ Verify that tool failures return user-friendly messages without Python tracebacks.
395
+
396
+ This tests that the agent:
397
+ - Returns human-readable error messages
398
+ - logs an error that can be reviewed in our dashboard/logs
399
+
400
+ Uses mocking to simulate tool failures without adding test-specific code to agent.py
401
+ """
402
+ logger.info(f"\n{'='*60}\nError Handling Test\n{'='*60}")
403
+
404
+ # Mock the Runner.run method to simulate a tool failure
405
+ # This tests the catch-all exception handler without adding test code to production
406
+ test_scenarios = [
407
+ RuntimeError("Simulated tool timeout"),
408
+ ValueError("Invalid tool parameters"),
409
+ ]
410
+
411
+ for error in test_scenarios:
412
+ logger.info(f"\nTesting error scenario: {error.__class__.__name__}: {error}")
413
+
414
+ # Clear previous log records for this iteration
415
+ caplog.clear()
416
+
417
+ # Mock Runner.run to raise an exception
418
+ with patch('agent.Runner.run', new_callable=AsyncMock) as mock_run:
419
+ mock_run.side_effect = error
420
+
421
+ response = await ai_me_agent.run("Any user question")
422
+
423
+ logger.info(f"Response: {response[:100]}...")
424
+
425
+ # PRIMARY CHECK: Verify "I encountered an unexpected error" is in response
426
+ assert "I encountered an unexpected error" in response, (
427
+ f"Response must contain 'I encountered an unexpected error'. Got: {response}"
428
+ )
429
+
430
+ # SECONDARY CHECK: Verify error was logged by agent.py
431
+ error_logs = [record for record in caplog.records if record.levelname == "ERROR"]
432
+ assert len(error_logs) > 0, "Expected at least one ERROR log record from agent.py"
433
+
434
+ # Find the agent.py error log (contains "Unexpected error:")
435
+ agent_error_logged = any(
436
+ "Unexpected error:" in record.message for record in error_logs
437
+ )
438
+ assert agent_error_logged, (
439
+ f"Expected ERROR log with 'Unexpected error:' from agent.py. "
440
+ f"Got: {[r.message for r in error_logs]}"
441
+ )
442
+ error_messages = [
443
+ r.message for r in error_logs
444
+ if "Unexpected error:" in r.message
445
+ ]
446
+ logger.info(
447
+ f"✓ Error properly logged to logger: {error_messages}"
448
+ )
449
+
450
+ logger.info("\n✓ Test passed: Error messages are friendly (FR-012) + properly logged")
451
+
452
+
453
+ @pytest.mark.asyncio
454
+ async def test_logger_setup_format(caplog):
455
+ """Tests NFR-003 (Structured Logging): Verify setup_logger creates structured logging.
456
+
457
+ Tests that setup_logger() configures syslog-style format with JSON support for
458
+ structured logging of user/agent interactions.
459
+
460
+ This validates the logger configuration that our production app relies on
461
+ for analytics and debugging.
462
+ """
463
+ # Force logger setup to run by clearing handlers so setup_logger reconfigures
464
+ root_logger = logging.getLogger()
465
+ original_handlers = root_logger.handlers[:]
466
+ for handler in root_logger.handlers[:]:
467
+ root_logger.removeHandler(handler)
468
+
469
+ try:
470
+ # Now call setup_logger with no handlers - should trigger full setup
471
+ test_logger = setup_logger("test.structured_logging")
472
+
473
+ # Verify logger was created
474
+ assert test_logger.name == "test.structured_logging"
475
+
476
+ # Verify root logger now has handlers (setup_logger should have added them)
477
+ assert len(root_logger.handlers) > 0, (
478
+ "Root logger should have handlers after setup_logger"
479
+ )
480
+
481
+ # Verify we have a StreamHandler (console output)
482
+ has_stream_handler = any(
483
+ isinstance(handler, logging.StreamHandler)
484
+ for handler in root_logger.handlers
485
+ )
486
+ assert has_stream_handler, "Should have StreamHandler for console output"
487
+
488
+ # Test that logging works with structured JSON format
489
+ # The formatters should support JSON logging for analytics
490
+ test_logger.info(
491
+ '{"session_id": "test-session", "user_input": "test message"}'
492
+ )
493
+
494
+ logger.info(
495
+ "✓ Test passed: Logger setup configures structured logging (NFR-003)"
496
+ )
497
+ finally:
498
+ # Restore original handlers
499
+ for handler in root_logger.handlers[:]:
500
+ root_logger.removeHandler(handler)
501
+ for handler in original_handlers:
502
+ root_logger.addHandler(handler)
503
+
504
+
505
+ if __name__ == "__main__":
506
+ # Allow running tests directly with python test.py
507
+ pytest.main([__file__, "-v", "-s"])
tests/unit/test_config.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for config.py Config and DataManagerConfig classes.
3
+
4
+ Tests configuration validation, Pydantic models, and environment variable parsing
5
+ in isolation without requiring full application setup.
6
+ """
7
+ import logging
8
+
9
+ from config import Config
10
+
11
+
12
+ def test_config_github_repos_parsing():
13
+ """Tests NFR-002 (Type-Safe Configuration): Config.parse_github_repos validator.
14
+
15
+ Validates that the field validator correctly parses comma-separated repository
16
+ strings from environment variables, including edge cases like empty strings and
17
+ pre-parsed lists. Ensures configuration is validated via Pydantic with strict
18
+ typing and no silent failures.
19
+ """
20
+ # Test empty string
21
+ result = Config.parse_github_repos("")
22
+ assert result == [], "Empty string should parse to empty list"
23
+
24
+ # Test single repo
25
+ result = Config.parse_github_repos("owner/repo")
26
+ assert result == ["owner/repo"], "Single repo should parse correctly"
27
+
28
+ # Test multiple repos with spaces
29
+ result = Config.parse_github_repos("owner1/repo1, owner2/repo2 , owner3/repo3")
30
+ assert result == ["owner1/repo1", "owner2/repo2", "owner3/repo3"], (
31
+ "Multiple repos with spaces should parse and strip correctly"
32
+ )
33
+
34
+ # Test already a list
35
+ result = Config.parse_github_repos(["owner/repo"])
36
+ assert result == ["owner/repo"], "Already a list should pass through"
tests/unit/test_data.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for data.py DataManager class.
3
+
4
+ Tests individual methods of the DataManager and DataManagerConfig in isolation,
5
+ without requiring external APIs or full integration setup.
6
+ """
7
+ import pytest
8
+ import os
9
+ from pathlib import Path
10
+ from unittest.mock import patch, MagicMock
11
+ from langchain_core.documents import Document
12
+
13
+ from data import DataManager, DataManagerConfig
14
+
15
+
16
+ class TestLoadLocalDocuments:
17
+ """Tests for DataManager.load_local_documents() method.
18
+
19
+ Implements FR-002 (Knowledge Retrieval): Document loading from local filesystem.
20
+ """
21
+
22
+ def test_load_local_documents_missing_directory(self):
23
+ """Tests FR-002: Handle missing doc_root gracefully.
24
+
25
+ When doc_root directory doesn't exist, load_local_documents should
26
+ return empty list and log warning instead of raising exception.
27
+ """
28
+ # Create config pointing to non-existent directory
29
+ config = DataManagerConfig(doc_root="/nonexistent/path/xyz")
30
+ dm = DataManager(config=config)
31
+
32
+ # Should return empty list, not raise exception
33
+ docs = dm.load_local_documents()
34
+
35
+ assert docs == [], "Expected empty list for missing directory"
36
+ assert isinstance(docs, list), "Expected list return type"
37
+
38
+ def test_load_local_documents_valid_directory(self):
39
+ """Tests FR-002: Load documents from existing directory.
40
+
41
+ When doc_root exists, load_local_documents should return loaded documents.
42
+ Uses tests/data directory which contains sample markdown files.
43
+ """
44
+ # Use test data directory
45
+ test_data_dir = str(Path(__file__).parent.parent / "data")
46
+ config = DataManagerConfig(doc_root=test_data_dir)
47
+ dm = DataManager(config=config)
48
+
49
+ # Should load documents
50
+ docs = dm.load_local_documents()
51
+
52
+ assert isinstance(docs, list), "Expected list return type"
53
+ assert len(docs) > 0, "Expected to find documents in tests/data"
54
+
55
+ # Verify documents have required metadata
56
+ for doc in docs:
57
+ assert "source" in doc.metadata, "Document should have source metadata"
58
+ assert doc.page_content, "Document should have content"
59
+
60
+ def test_load_local_documents_multiple_glob_patterns(self):
61
+ """Tests FR-002: Load documents using multiple glob patterns (lines 81-83).
62
+
63
+ Tests the for loop iteration over multiple glob patterns in load_local_documents.
64
+ This covers lines 81-83 where patterns are iterated and loaded.
65
+ """
66
+ # Use test data directory
67
+ test_data_dir = str(Path(__file__).parent.parent / "data")
68
+
69
+ # Create config with multiple glob patterns
70
+ config = DataManagerConfig(
71
+ doc_root=test_data_dir,
72
+ doc_load_local=["*.md", "**/*.md"] # Multiple patterns
73
+ )
74
+ dm = DataManager(config=config)
75
+
76
+ # Should load documents from all patterns
77
+ docs = dm.load_local_documents()
78
+
79
+ assert isinstance(docs, list), "Expected list return type"
80
+ assert len(docs) > 0, "Expected to find documents with multiple patterns"
81
+
82
+ # Verify all patterns were processed (should have more docs due to overlap)
83
+ assert len(docs) >= 3, "Expected at least 3 docs from test data"
84
+
85
+
86
+ class TestProcessDocuments:
87
+ """Tests for DataManager.process_documents() method.
88
+
89
+ Implements FR-004 (Source Attribution): Converting relative GitHub links
90
+ to absolute URLs in markdown documents.
91
+ """
92
+
93
+ def test_process_documents_converts_relative_links_to_absolute(self):
94
+ """Tests FR-004: Relative GitHub links converted to absolute URLs.
95
+
96
+ Verifies that process_documents rewrites relative links like /path/file.md
97
+ to absolute GitHub URLs like https://github.com/owner/repo/blob/main/path/file.md
98
+ """
99
+ # Create a sample document with relative GitHub links
100
+ sample_doc = Document(
101
+ page_content=(
102
+ "Check out [my resume](/resume.md) and "
103
+ "[projects](/projects.md) for more info."
104
+ ),
105
+ metadata={
106
+ "source": "github://byoung/ai-me/docs/about.md",
107
+ "github_repo": "byoung/ai-me"
108
+ }
109
+ )
110
+
111
+ config = DataManagerConfig()
112
+ dm = DataManager(config=config)
113
+
114
+ # Process the document
115
+ processed_docs = dm.process_documents([sample_doc])
116
+
117
+ assert len(processed_docs) == 1, "Expected 1 processed document"
118
+ processed_content = processed_docs[0].page_content
119
+
120
+ # Verify relative links were converted to absolute GitHub URLs
121
+ assert "https://github.com/byoung/ai-me/blob/main/resume.md" in processed_content, (
122
+ f"Expected absolute URL for /resume.md in: {processed_content}"
123
+ )
124
+ assert "https://github.com/byoung/ai-me/blob/main/projects.md" in processed_content, (
125
+ f"Expected absolute URL for /projects.md in: {processed_content}"
126
+ )
127
+
128
+ def test_process_documents_preserves_non_github_docs(self):
129
+ """Tests FR-004: Non-GitHub documents are preserved unchanged.
130
+
131
+ Documents without github_repo metadata should pass through unchanged.
132
+ """
133
+ # Create a document without github_repo metadata
134
+ sample_doc = Document(
135
+ page_content="[my resume](/resume.md)",
136
+ metadata={
137
+ "source": "local://docs/about.md"
138
+ }
139
+ )
140
+
141
+ config = DataManagerConfig()
142
+ dm = DataManager(config=config)
143
+
144
+ # Process the document
145
+ processed_docs = dm.process_documents([sample_doc])
146
+
147
+ assert len(processed_docs) == 1, "Expected 1 processed document"
148
+ # Content should be unchanged (no github_repo in metadata)
149
+ assert processed_docs[0].page_content == "[my resume](/resume.md)", (
150
+ "Non-GitHub document should not be modified"
151
+ )
152
+
153
+ def test_process_documents_handles_markdown_with_anchors(self):
154
+ """Tests FR-004: Markdown links with anchor fragments are preserved.
155
+
156
+ Links like [text](/file.md#section) should preserve the anchor in the URL.
157
+ """
158
+ sample_doc = Document(
159
+ page_content="See [section](/docs/guide.md#installation) for details.",
160
+ metadata={
161
+ "source": "github://user/repo/README.md",
162
+ "github_repo": "user/repo"
163
+ }
164
+ )
165
+
166
+ config = DataManagerConfig()
167
+ dm = DataManager(config=config)
168
+
169
+ processed_docs = dm.process_documents([sample_doc])
170
+ processed_content = processed_docs[0].page_content
171
+
172
+ # Verify anchor is preserved in the URL
173
+ assert "https://github.com/user/repo/blob/main/docs/guide.md#installation" in processed_content, (
174
+ f"Expected anchor preserved in URL in: {processed_content}"
175
+ )
uv.lock CHANGED
@@ -5,7 +5,7 @@ requires-python = "==3.12.*"
5
  [[package]]
6
  name = "ai-me"
7
  version = "0.1.0"
8
- source = { virtual = "." }
9
  dependencies = [
10
  { name = "chromadb" },
11
  { name = "fastmcp" },
@@ -36,6 +36,7 @@ dev = [
36
  { name = "ipywidgets" },
37
  { name = "pytest" },
38
  { name = "pytest-asyncio" },
 
39
  ]
40
 
41
  [package.metadata]
@@ -69,6 +70,7 @@ dev = [
69
  { name = "ipywidgets", specifier = "~=8.1" },
70
  { name = "pytest", specifier = "~=8.0" },
71
  { name = "pytest-asyncio", specifier = "~=0.24" },
 
72
  ]
73
 
74
  [[package]]
@@ -418,6 +420,28 @@ wheels = [
418
  { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" },
419
  ]
420
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
  [[package]]
422
  name = "cryptography"
423
  version = "46.0.2"
@@ -2331,6 +2355,20 @@ wheels = [
2331
  { url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694, upload-time = "2025-03-25T06:22:27.807Z" },
2332
  ]
2333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2334
  [[package]]
2335
  name = "python-dateutil"
2336
  version = "2.9.0.post0"
 
5
  [[package]]
6
  name = "ai-me"
7
  version = "0.1.0"
8
+ source = { editable = "." }
9
  dependencies = [
10
  { name = "chromadb" },
11
  { name = "fastmcp" },
 
36
  { name = "ipywidgets" },
37
  { name = "pytest" },
38
  { name = "pytest-asyncio" },
39
+ { name = "pytest-cov" },
40
  ]
41
 
42
  [package.metadata]
 
70
  { name = "ipywidgets", specifier = "~=8.1" },
71
  { name = "pytest", specifier = "~=8.0" },
72
  { name = "pytest-asyncio", specifier = "~=0.24" },
73
+ { name = "pytest-cov", specifier = "~=6.0" },
74
  ]
75
 
76
  [[package]]
 
420
  { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" },
421
  ]
422
 
423
+ [[package]]
424
+ name = "coverage"
425
+ version = "7.11.0"
426
+ source = { registry = "https://pypi.org/simple" }
427
+ sdist = { url = "https://files.pythonhosted.org/packages/1c/38/ee22495420457259d2f3390309505ea98f98a5eed40901cf62196abad006/coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050", size = 811905, upload-time = "2025-10-15T15:15:08.542Z" }
428
+ wheels = [
429
+ { url = "https://files.pythonhosted.org/packages/c4/db/86f6906a7c7edc1a52b2c6682d6dd9be775d73c0dfe2b84f8923dfea5784/coverage-7.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9c49e77811cf9d024b95faf86c3f059b11c0c9be0b0d61bc598f453703bd6fd1", size = 216098, upload-time = "2025-10-15T15:13:02.916Z" },
430
+ { url = "https://files.pythonhosted.org/packages/21/54/e7b26157048c7ba555596aad8569ff903d6cd67867d41b75287323678ede/coverage-7.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a61e37a403a778e2cda2a6a39abcc895f1d984071942a41074b5c7ee31642007", size = 216331, upload-time = "2025-10-15T15:13:04.403Z" },
431
+ { url = "https://files.pythonhosted.org/packages/b9/19/1ce6bf444f858b83a733171306134a0544eaddf1ca8851ede6540a55b2ad/coverage-7.11.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c79cae102bb3b1801e2ef1511fb50e91ec83a1ce466b2c7c25010d884336de46", size = 247825, upload-time = "2025-10-15T15:13:05.92Z" },
432
+ { url = "https://files.pythonhosted.org/packages/71/0b/d3bcbbc259fcced5fb67c5d78f6e7ee965f49760c14afd931e9e663a83b2/coverage-7.11.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16ce17ceb5d211f320b62df002fa7016b7442ea0fd260c11cec8ce7730954893", size = 250573, upload-time = "2025-10-15T15:13:07.471Z" },
433
+ { url = "https://files.pythonhosted.org/packages/58/8d/b0ff3641a320abb047258d36ed1c21d16be33beed4152628331a1baf3365/coverage-7.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80027673e9d0bd6aef86134b0771845e2da85755cf686e7c7c59566cf5a89115", size = 251706, upload-time = "2025-10-15T15:13:09.4Z" },
434
+ { url = "https://files.pythonhosted.org/packages/59/c8/5a586fe8c7b0458053d9c687f5cff515a74b66c85931f7fe17a1c958b4ac/coverage-7.11.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d3ffa07a08657306cd2215b0da53761c4d73cb54d9143b9303a6481ec0cd415", size = 248221, upload-time = "2025-10-15T15:13:10.964Z" },
435
+ { url = "https://files.pythonhosted.org/packages/d0/ff/3a25e3132804ba44cfa9a778cdf2b73dbbe63ef4b0945e39602fc896ba52/coverage-7.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a3b6a5f8b2524fd6c1066bc85bfd97e78709bb5e37b5b94911a6506b65f47186", size = 249624, upload-time = "2025-10-15T15:13:12.5Z" },
436
+ { url = "https://files.pythonhosted.org/packages/c5/12/ff10c8ce3895e1b17a73485ea79ebc1896a9e466a9d0f4aef63e0d17b718/coverage-7.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fcc0a4aa589de34bc56e1a80a740ee0f8c47611bdfb28cd1849de60660f3799d", size = 247744, upload-time = "2025-10-15T15:13:14.554Z" },
437
+ { url = "https://files.pythonhosted.org/packages/16/02/d500b91f5471b2975947e0629b8980e5e90786fe316b6d7299852c1d793d/coverage-7.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dba82204769d78c3fd31b35c3d5f46e06511936c5019c39f98320e05b08f794d", size = 247325, upload-time = "2025-10-15T15:13:16.438Z" },
438
+ { url = "https://files.pythonhosted.org/packages/77/11/dee0284fbbd9cd64cfce806b827452c6df3f100d9e66188e82dfe771d4af/coverage-7.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81b335f03ba67309a95210caf3eb43bd6fe75a4e22ba653ef97b4696c56c7ec2", size = 249180, upload-time = "2025-10-15T15:13:17.959Z" },
439
+ { url = "https://files.pythonhosted.org/packages/59/1b/cdf1def928f0a150a057cab03286774e73e29c2395f0d30ce3d9e9f8e697/coverage-7.11.0-cp312-cp312-win32.whl", hash = "sha256:037b2d064c2f8cc8716fe4d39cb705779af3fbf1ba318dc96a1af858888c7bb5", size = 218479, upload-time = "2025-10-15T15:13:19.608Z" },
440
+ { url = "https://files.pythonhosted.org/packages/ff/55/e5884d55e031da9c15b94b90a23beccc9d6beee65e9835cd6da0a79e4f3a/coverage-7.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:d66c0104aec3b75e5fd897e7940188ea1892ca1d0235316bf89286d6a22568c0", size = 219290, upload-time = "2025-10-15T15:13:21.593Z" },
441
+ { url = "https://files.pythonhosted.org/packages/23/a8/faa930cfc71c1d16bc78f9a19bb73700464f9c331d9e547bfbc1dbd3a108/coverage-7.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:d91ebeac603812a09cf6a886ba6e464f3bbb367411904ae3790dfe28311b15ad", size = 217924, upload-time = "2025-10-15T15:13:23.39Z" },
442
+ { url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" },
443
+ ]
444
+
445
  [[package]]
446
  name = "cryptography"
447
  version = "46.0.2"
 
2355
  { url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694, upload-time = "2025-03-25T06:22:27.807Z" },
2356
  ]
2357
 
2358
+ [[package]]
2359
+ name = "pytest-cov"
2360
+ version = "6.3.0"
2361
+ source = { registry = "https://pypi.org/simple" }
2362
+ dependencies = [
2363
+ { name = "coverage" },
2364
+ { name = "pluggy" },
2365
+ { name = "pytest" },
2366
+ ]
2367
+ sdist = { url = "https://files.pythonhosted.org/packages/30/4c/f883ab8f0daad69f47efdf95f55a66b51a8b939c430dadce0611508d9e99/pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2", size = 70398, upload-time = "2025-09-06T15:40:14.361Z" }
2368
+ wheels = [
2369
+ { url = "https://files.pythonhosted.org/packages/80/b4/bb7263e12aade3842b938bc5c6958cae79c5ee18992f9b9349019579da0f/pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749", size = 25115, upload-time = "2025-09-06T15:40:12.44Z" },
2370
+ ]
2371
+
2372
  [[package]]
2373
  name = "python-dateutil"
2374
  version = "2.9.0.post0"