diff --git a/pyproject.toml b/pyproject.toml
index 401d96b..3a300f6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -37,6 +37,9 @@ dependencies = [
"rendercv[full]>=2.7",
"pypdf>=6.9.1",
"prompt_toolkit>=3.0",
+ "python-docx>=1.1",
+ "beautifulsoup4>=4.12",
+ "html2text>=2024.2",
]
[project.urls]
@@ -49,6 +52,11 @@ Changelog = "https://github.com/bkuberek/mkcv/blob/main/CHANGELOG.md"
mkcv = "mkcv.cli.app:main"
[project.optional-dependencies]
+kb = [
+ "python-docx>=1.1",
+ "beautifulsoup4>=4.12",
+ "html2text>=2024.2",
+]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
@@ -90,7 +98,7 @@ warn_unused_configs = true
plugins = ["pydantic.mypy"]
[[tool.mypy.overrides]]
-module = ["dynaconf", "anthropic", "anthropic.*", "openai", "openai.*", "rendercv", "rendercv.*"]
+module = ["dynaconf", "anthropic", "anthropic.*", "openai", "openai.*", "rendercv", "rendercv.*", "docx", "docx.*", "bs4", "bs4.*", "html2text"]
ignore_missing_imports = true
[tool.pytest.ini_options]
diff --git a/src/mkcv/adapters/factory.py b/src/mkcv/adapters/factory.py
index 3cba2de..ce5746b 100644
--- a/src/mkcv/adapters/factory.py
+++ b/src/mkcv/adapters/factory.py
@@ -16,6 +16,7 @@
from mkcv.config.configuration import Configuration
from mkcv.core.ports.llm import LLMPort
from mkcv.core.services.batch_render import BatchRenderService
+ from mkcv.core.services.kb_generation_service import KBGenerationService
from mkcv.core.services.regeneration import RegenerationService
from mkcv.adapters.filesystem.artifact_store import FileSystemArtifactStore
@@ -708,3 +709,69 @@ def _create_prompt_loader(config: Configuration) -> FileSystemPromptLoader:
override_dir = templates_dir
return FileSystemPromptLoader(override_dir=override_dir)
+
+
+def create_kb_generation_service(
+ config: Configuration,
+ *,
+ provider_override: str | None = None,
+ model_override: str | None = None,
+) -> KBGenerationService:
+ """Create a fully-wired KBGenerationService.
+
+ Reads KB-specific LLM settings from ``[kb]`` config section and
+ creates a ``KBGenerationService`` with a ``MultiFormatDocumentReader``,
+ LLM adapter, and prompt loader.
+
+ Args:
+ config: Application configuration.
+ provider_override: When set, override the LLM provider.
+ model_override: When set, override the LLM model.
+
+ Returns:
+ KBGenerationService with all dependencies wired.
+ """
+ from mkcv.adapters.filesystem.document_reader import MultiFormatDocumentReader
+ from mkcv.core.services.kb_generation_service import KBGenerationService
+
+ # Read KB config section with defaults
+ _default_model = "claude-sonnet-4-20250514"
+ try:
+ kb_section = getattr(config, "kb", None)
+ if kb_section is not None:
+ provider = str(getattr(kb_section, "provider", "anthropic"))
+ model = str(getattr(kb_section, "model", _default_model))
+ temperature = float(getattr(kb_section, "temperature", 0.3))
+ max_tokens = int(getattr(kb_section, "max_tokens", 8192))
+ chunk_threshold = int(getattr(kb_section, "chunk_threshold", 100000))
+ else:
+ provider = "anthropic"
+ model = _default_model
+ temperature = 0.3
+ max_tokens = 8192
+ chunk_threshold = 100000
+ except (AttributeError, TypeError, ValueError):
+ provider = "anthropic"
+ model = _default_model
+ temperature = 0.3
+ max_tokens = 8192
+ chunk_threshold = 100000
+
+ if provider_override is not None:
+ provider = provider_override
+ if model_override is not None:
+ model = model_override
+
+ llm = _create_llm_adapter(provider, config)
+ prompts = _create_prompt_loader(config)
+ document_reader = MultiFormatDocumentReader()
+
+ return KBGenerationService(
+ document_reader=document_reader,
+ llm=llm,
+ prompts=prompts,
+ model=model,
+ temperature=temperature,
+ max_tokens=max_tokens,
+ chunk_threshold=chunk_threshold,
+ )
diff --git a/src/mkcv/adapters/filesystem/document_reader.py b/src/mkcv/adapters/filesystem/document_reader.py
new file mode 100644
index 0000000..f716c7e
--- /dev/null
+++ b/src/mkcv/adapters/filesystem/document_reader.py
@@ -0,0 +1,265 @@
+"""Multi-format document reader adapter."""
+
+import logging
+import warnings
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from mkcv.core.exceptions.kb_generation import DocumentReadError
+from mkcv.core.models.document_content import DocumentContent
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Extension → format mapping
+# ---------------------------------------------------------------------------
+
+_EXTENSION_FORMAT: dict[str, str] = {
+ ".pdf": "pdf",
+ ".md": "markdown",
+ ".markdown": "markdown",
+ ".txt": "text",
+ ".text": "text",
+ ".docx": "docx",
+ ".html": "html",
+ ".htm": "html",
+}
+
+
+class MultiFormatDocumentReader:
+ """Reads text content from PDF, Markdown, plain-text, DOCX, and HTML files.
+
+ Implements DocumentReaderPort for use in KBGenerationService.
+ """
+
+ # ------------------------------------------------------------------
+ # Public interface (DocumentReaderPort)
+ # ------------------------------------------------------------------
+
+ def read_file(self, path: Path) -> DocumentContent:
+ """Read text content from a single document file.
+
+ Args:
+ path: Path to the document file.
+
+ Returns:
+ Extracted document content with metadata.
+
+ Raises:
+ DocumentReadError: If the file cannot be read or the format
+ is unsupported.
+ """
+ if not path.is_file():
+ raise DocumentReadError(f"File not found: {path}")
+
+ ext = path.suffix.lower()
+ fmt = _EXTENSION_FORMAT.get(ext)
+ if fmt is None:
+ raise DocumentReadError(
+ f"Unsupported file format '{ext}' for '{path.name}'. "
+ f"Supported: {', '.join(sorted(self.supported_extensions()))}"
+ )
+
+ dispatch: dict[str, Callable[[Path], tuple[str, dict[str, str]]]] = {
+ "pdf": self._read_pdf,
+ "markdown": self._read_markdown,
+ "text": self._read_text,
+ "docx": self._read_docx,
+ "html": self._read_html,
+ }
+
+ reader = dispatch[fmt]
+ text, metadata = reader(path)
+
+ return DocumentContent(
+ text=text,
+ source_path=path,
+ format=fmt,
+ char_count=len(text),
+ metadata=metadata,
+ )
+
+ def supported_extensions(self) -> set[str]:
+ """Return the set of file extensions this reader supports.
+
+ Returns:
+ Set of lowercase extensions including the dot,
+ e.g. ``{".pdf", ".md", ".txt", ".docx", ".html"}``.
+ """
+ return set(_EXTENSION_FORMAT.keys())
+
+ # ------------------------------------------------------------------
+ # Directory scanning
+ # ------------------------------------------------------------------
+
+ def read_sources(
+ self,
+ paths: list[Path],
+ *,
+ glob: str = "**/*",
+ ) -> list[DocumentContent]:
+ """Resolve *paths* (files or directories) and read all supported documents.
+
+ Args:
+ paths: Files and/or directories to read.
+ glob: Glob pattern applied when scanning directories.
+ Defaults to ``**/*`` (recursive, all files).
+
+ Returns:
+ List of ``DocumentContent`` in sorted order (by source path).
+
+ Raises:
+ DocumentReadError: Propagated from individual file reads.
+ """
+ discovered: set[Path] = set()
+ supported = self.supported_extensions()
+
+ for p in paths:
+ resolved = p.resolve()
+ if resolved.is_file():
+ if resolved.suffix.lower() in supported:
+ discovered.add(resolved)
+ else:
+ logger.debug(
+ "Skipping unsupported file: %s",
+ resolved,
+ )
+ elif resolved.is_dir():
+ for child in resolved.glob(glob):
+ if child.is_file() and child.suffix.lower() in supported:
+ discovered.add(child)
+ else:
+ logger.warning("Path does not exist, skipping: %s", resolved)
+
+ results: list[DocumentContent] = []
+ for file_path in sorted(discovered):
+ logger.info("Reading %s", file_path)
+ results.append(self.read_file(file_path))
+
+ return results
+
+ # ------------------------------------------------------------------
+ # Private format readers
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _read_pdf(path: Path) -> tuple[str, dict[str, str]]:
+ """Extract text from a PDF using *pypdf*."""
+ from pypdf import PdfReader
+ from pypdf.errors import PdfReadError
+
+ try:
+ reader = PdfReader(path)
+ except PdfReadError as exc:
+ raise DocumentReadError(
+ f"Cannot read PDF '{path.name}': file is corrupted or not a valid PDF."
+ ) from exc
+
+ metadata: dict[str, str] = {}
+ if reader.metadata:
+ if reader.metadata.title:
+ metadata["title"] = reader.metadata.title
+ if reader.metadata.author:
+ metadata["author"] = reader.metadata.author
+
+ pages: list[str] = []
+ for page_num, page in enumerate(reader.pages, start=1):
+ try:
+ text = page.extract_text() or ""
+ except PdfReadError as exc:
+ logger.warning(
+ "Failed to extract text from page %d of '%s': %s",
+ page_num,
+ path.name,
+ exc,
+ )
+ continue
+ pages.append(text)
+
+ text = "\n".join(pages)
+
+ if not text.strip():
+ warnings.warn(
+ f"PDF '{path.name}' appears to be image-only; "
+ "no extractable text was found.",
+ stacklevel=2,
+ )
+
+ return text, metadata
+
+ @staticmethod
+ def _read_markdown(path: Path) -> tuple[str, dict[str, str]]:
+ """Read a Markdown file as plain text."""
+ try:
+ text = path.read_text(encoding="utf-8")
+ except (OSError, UnicodeDecodeError) as exc:
+ raise DocumentReadError(
+ f"Cannot read Markdown file '{path.name}': {exc}"
+ ) from exc
+ return text, {}
+
+ @staticmethod
+ def _read_text(path: Path) -> tuple[str, dict[str, str]]:
+ """Read a plain-text file."""
+ try:
+ text = path.read_text(encoding="utf-8")
+ except (OSError, UnicodeDecodeError) as exc:
+ raise DocumentReadError(
+ f"Cannot read text file '{path.name}': {exc}"
+ ) from exc
+ return text, {}
+
+ @staticmethod
+ def _read_docx(path: Path) -> tuple[str, dict[str, str]]:
+ """Extract text from a DOCX file using *python-docx*."""
+ import docx as python_docx
+
+ try:
+ doc = python_docx.Document(str(path))
+ except Exception as exc:
+ raise DocumentReadError(
+ f"Cannot read DOCX file '{path.name}': {exc}"
+ ) from exc
+
+ paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
+ text = "\n\n".join(paragraphs)
+
+ metadata: dict[str, str] = {}
+ core = doc.core_properties
+ if core.title:
+ metadata["title"] = core.title
+ if core.author:
+ metadata["author"] = core.author
+
+ return text, metadata
+
+ @staticmethod
+ def _read_html(path: Path) -> tuple[str, dict[str, str]]:
+ """Extract text from an HTML file using *beautifulsoup4* + *html2text*."""
+ import html2text
+ from bs4 import BeautifulSoup
+
+ try:
+ raw = path.read_text(encoding="utf-8")
+ except (OSError, UnicodeDecodeError) as exc:
+ raise DocumentReadError(
+ f"Cannot read HTML file '{path.name}': {exc}"
+ ) from exc
+
+ soup = BeautifulSoup(raw, "html.parser")
+
+ metadata: dict[str, str] = {}
+ title_tag = soup.find("title")
+ if title_tag and title_tag.string:
+ metadata["title"] = title_tag.string.strip()
+
+ converter = html2text.HTML2Text()
+ converter.ignore_links = False
+ converter.ignore_images = True
+ converter.body_width = 0 # no wrapping
+ text: str = converter.handle(raw)
+
+ return text, metadata
diff --git a/src/mkcv/cli/app.py b/src/mkcv/cli/app.py
index d4acc75..62d2884 100644
--- a/src/mkcv/cli/app.py
+++ b/src/mkcv/cli/app.py
@@ -1,7 +1,7 @@
"""mkcv CLI application.
Cyclopts-based CLI with global options (--verbose, --workspace, --version)
-and subcommands: generate, render, validate, init, themes.
+and subcommands: generate, render, validate, init, themes, kb.
"""
import logging
@@ -40,6 +40,7 @@
app.command("mkcv.cli.commands.themes:themes_command", name="themes")
app.command("mkcv.cli.commands.status:status_command", name="status")
app.command("mkcv.cli.commands.cover_letter:cover_letter_command", name="cover-letter")
+app.command("mkcv.cli.commands.kb:kb_app", name="kb")
# ---------------------------------------------------------------------------
diff --git a/src/mkcv/cli/commands/kb.py b/src/mkcv/cli/commands/kb.py
new file mode 100644
index 0000000..8360a8f
--- /dev/null
+++ b/src/mkcv/cli/commands/kb.py
@@ -0,0 +1,338 @@
+"""mkcv kb — generate or update a career knowledge base from documents."""
+
+import asyncio
+import logging
+import sys
+from pathlib import Path
+from typing import Annotated
+
+import cyclopts
+from rich.console import Console
+
+from mkcv.adapters.factory import create_kb_generation_service
+from mkcv.config import settings
+from mkcv.core.exceptions import MkcvError
+from mkcv.core.models.kb_generation_result import KBGenerationResult
+
+logger = logging.getLogger(__name__)
+
+console = Console(stderr=True)
+output_console = Console()
+
+kb_app = cyclopts.App(
+ name="kb",
+ help="Generate or update a career knowledge base from source documents.",
+)
+
+
+@kb_app.command(name="generate")
+def kb_generate_command(
+ sources: Annotated[
+ list[Path],
+ cyclopts.Parameter(
+ help=(
+ "Source file(s) or directory paths containing career documents "
+ "(PDF, Markdown, DOCX, HTML, TXT)."
+ ),
+ ),
+ ],
+ *,
+ output: Annotated[
+ Path | None,
+ cyclopts.Parameter(
+ name=["--output", "-o"],
+ help="Output path for the generated knowledge base file.",
+ ),
+ ] = None,
+ name: Annotated[
+ str,
+ cyclopts.Parameter(
+ name=["--name", "-n"],
+ help="KB name used in the title heading.",
+ ),
+ ] = "Career",
+ glob: Annotated[
+ str | None,
+ cyclopts.Parameter(
+ name=["--glob", "-g"],
+ help="Glob pattern to filter files when scanning directories.",
+ ),
+ ] = None,
+ model: Annotated[
+ str | None,
+ cyclopts.Parameter(
+ name=["--model", "-m"],
+ help="Override the LLM model for KB generation.",
+ ),
+ ] = None,
+ provider: Annotated[
+ str | None,
+ cyclopts.Parameter(
+ help=("Override AI provider (anthropic/openai/openrouter/ollama)."),
+ ),
+ ] = None,
+) -> None:
+ """Generate a knowledge base from source documents.
+
+ Reads career documents (PDF, Markdown, DOCX, HTML, TXT) from the
+ given sources and uses an LLM to synthesise a structured Markdown
+ knowledge base suitable for resume generation.
+
+ Sources can be individual files or directories. When a directory is
+ given, all supported files are discovered recursively (use --glob to
+ filter).
+
+ Examples:
+ mkcv kb generate resume.pdf
+ mkcv kb generate docs/ --output kb.md --name "Engineering"
+ mkcv kb generate resume.pdf linkedin.html --glob "*.pdf"
+ """
+ # Resolve output path: default to workspace KB path or career-kb.md
+ resolved_output = _resolve_output(output)
+
+ # Display header
+ console.print()
+ console.print(" [bold]mkcv kb generate[/bold]")
+ console.print(f" Sources: {', '.join(str(s) for s in sources)}")
+ console.print(f" Name: {name}")
+ if glob:
+ console.print(f" Glob: {glob}")
+ console.print(f" Output: {resolved_output}")
+ console.print()
+
+ # Create and run the service
+ try:
+ service = create_kb_generation_service(
+ settings,
+ provider_override=provider,
+ model_override=model,
+ )
+ except MkcvError as exc:
+ console.print(f"[red]Error:[/red] {exc}")
+ sys.exit(exc.exit_code)
+
+ with console.status(" Generating knowledge base..."):
+ try:
+ result = asyncio.run(
+ service.generate(
+ sources=sources,
+ output=resolved_output,
+ kb_name=name,
+ glob=glob,
+ )
+ )
+ except MkcvError as exc:
+ console.print(f" [red]Error:[/red] {exc}")
+ sys.exit(exc.exit_code)
+
+ # Display results
+ _display_generate_result(result)
+
+
+@kb_app.command(name="update")
+def kb_update_command(
+ sources: Annotated[
+ list[Path],
+ cyclopts.Parameter(
+ help=(
+ "New source file(s) or directory paths to merge into "
+ "the existing knowledge base."
+ ),
+ ),
+ ],
+ *,
+ kb: Annotated[
+ Path | None,
+ cyclopts.Parameter(
+ help=(
+ "Path to existing knowledge base to update. "
+ "Defaults to the workspace KB path."
+ ),
+ ),
+ ] = None,
+ glob: Annotated[
+ str | None,
+ cyclopts.Parameter(
+ name=["--glob", "-g"],
+ help="Glob pattern to filter files when scanning directories.",
+ ),
+ ] = None,
+ model: Annotated[
+ str | None,
+ cyclopts.Parameter(
+ name=["--model", "-m"],
+ help="Override the LLM model for KB generation.",
+ ),
+ ] = None,
+ provider: Annotated[
+ str | None,
+ cyclopts.Parameter(
+ help=("Override AI provider (anthropic/openai/openrouter/ollama)."),
+ ),
+ ] = None,
+) -> None:
+ """Update an existing knowledge base with new source documents.
+
+ Reads new career documents and merges them into an existing knowledge
+ base, preserving the existing structure while integrating new content.
+
+ If --kb is not specified, the workspace knowledge base path is used.
+
+ Examples:
+ mkcv kb update new-cert.pdf
+ mkcv kb update promotion-docs/ --kb my-kb.md
+ mkcv kb update new-role.md --glob "*.md"
+ """
+ # Resolve existing KB path
+ resolved_kb = _resolve_existing_kb(kb)
+
+ if resolved_kb is None:
+ console.print(
+ "[red]Error:[/red] No existing knowledge base found. "
+ "Use --kb to specify the path, or run 'mkcv kb generate' first."
+ )
+ sys.exit(2)
+
+ if not resolved_kb.is_file():
+ console.print(f"[red]Error:[/red] Knowledge base not found: {resolved_kb}")
+ sys.exit(2)
+
+ # Display header
+ console.print()
+ console.print(" [bold]mkcv kb update[/bold]")
+ console.print(f" KB: {resolved_kb}")
+ console.print(f" Sources: {', '.join(str(s) for s in sources)}")
+ if glob:
+ console.print(f" Glob: {glob}")
+ console.print()
+
+ # Create and run the service
+ try:
+ service = create_kb_generation_service(
+ settings,
+ provider_override=provider,
+ model_override=model,
+ )
+ except MkcvError as exc:
+ console.print(f"[red]Error:[/red] {exc}")
+ sys.exit(exc.exit_code)
+
+ with console.status(" Updating knowledge base..."):
+ try:
+ result = asyncio.run(
+ service.update(
+ existing_kb_path=resolved_kb,
+ sources=sources,
+ glob=glob,
+ )
+ )
+ except MkcvError as exc:
+ console.print(f" [red]Error:[/red] {exc}")
+ sys.exit(exc.exit_code)
+
+ # Display results
+ _display_update_result(result)
+
+
+# ------------------------------------------------------------------
+# Path resolution helpers
+# ------------------------------------------------------------------
+
+
+def _resolve_output(output: Path | None) -> Path:
+ """Resolve the output path for a generated knowledge base.
+
+ When no explicit output is given, uses the workspace KB path
+ (if in a workspace) or defaults to ``career-kb.md`` in the
+ current directory.
+
+ Args:
+ output: Explicit output path, or None.
+
+ Returns:
+ Resolved output path.
+ """
+ if output is not None:
+ return output
+
+ if settings.in_workspace and settings.workspace_root:
+ kb_relative: str = settings.workspace.knowledge_base
+ return Path(settings.workspace_root / kb_relative)
+
+ return Path.cwd() / "career-kb.md"
+
+
+def _resolve_existing_kb(kb: Path | None) -> Path | None:
+ """Resolve the path to an existing knowledge base.
+
+ When no explicit path is given, tries the workspace KB path.
+
+ Args:
+ kb: Explicit KB path, or None.
+
+ Returns:
+ Resolved path, or None if no KB can be found.
+ """
+ if kb is not None:
+ return kb
+
+ if settings.in_workspace and settings.workspace_root:
+ kb_relative: str = settings.workspace.knowledge_base
+ candidate = Path(settings.workspace_root / kb_relative)
+ if candidate.is_file():
+ return candidate
+
+ return None
+
+
+# ------------------------------------------------------------------
+# Display helpers
+# ------------------------------------------------------------------
+
+
+def _display_generate_result(
+ result: KBGenerationResult,
+) -> None:
+ """Display knowledge base generation results."""
+ doc_count = len(result.source_documents)
+ total_chars = sum(d.char_count for d in result.source_documents)
+ kb_chars = len(result.kb_text)
+
+ console.print(
+ f" [green]Done.[/green] "
+ f"Read {doc_count} document(s) ({total_chars:,} chars) "
+ f"-> KB ({kb_chars:,} chars)"
+ )
+
+ if result.output_path is not None:
+ console.print(f" Output: {result.output_path}")
+
+ if result.validation_warnings:
+ console.print()
+ for warning in result.validation_warnings:
+ console.print(f" [yellow]Warning:[/yellow] {warning}")
+
+ console.print()
+
+
+def _display_update_result(
+ result: KBGenerationResult,
+) -> None:
+ """Display knowledge base update results."""
+ doc_count = len(result.source_documents)
+ kb_chars = len(result.kb_text)
+
+ console.print(
+ f" [green]Done.[/green] "
+ f"Merged {doc_count} new document(s) -> KB ({kb_chars:,} chars)"
+ )
+
+ if result.output_path is not None:
+ console.print(f" Output: {result.output_path}")
+
+ if result.validation_warnings:
+ console.print()
+ for warning in result.validation_warnings:
+ console.print(f" [yellow]Warning:[/yellow] {warning}")
+
+ console.print()
diff --git a/src/mkcv/config/settings.toml b/src/mkcv/config/settings.toml
index 4fc864d..6476eee 100644
--- a/src/mkcv/config/settings.toml
+++ b/src/mkcv/config/settings.toml
@@ -64,6 +64,13 @@ temperature = 0.2
# line_spacing = "0.7em"
# default_salutation = "Dear Hiring Manager,"
+[default.kb]
+provider = "anthropic"
+model = "claude-sonnet-4-20250514"
+temperature = 0.3
+max_tokens = 8192
+chunk_threshold = 100000
+
[default.rendering]
theme = "sb2nov"
font = "SourceSansPro"
diff --git a/src/mkcv/core/exceptions/__init__.py b/src/mkcv/core/exceptions/__init__.py
index 88c7158..c0d13bb 100644
--- a/src/mkcv/core/exceptions/__init__.py
+++ b/src/mkcv/core/exceptions/__init__.py
@@ -10,6 +10,7 @@
from mkcv.core.exceptions.context_length import ContextLengthError
from mkcv.core.exceptions.cover_letter import CoverLetterError
from mkcv.core.exceptions.jd_read import JDReadError
+from mkcv.core.exceptions.kb_generation import DocumentReadError, KBGenerationError
from mkcv.core.exceptions.pipeline_stage import PipelineStageError
from mkcv.core.exceptions.provider import ProviderError
from mkcv.core.exceptions.rate_limit import RateLimitError
@@ -26,7 +27,9 @@
"AuthenticationError",
"ContextLengthError",
"CoverLetterError",
+ "DocumentReadError",
"JDReadError",
+ "KBGenerationError",
"MkcvError",
"PipelineStageError",
"ProviderError",
diff --git a/src/mkcv/core/exceptions/kb_generation.py b/src/mkcv/core/exceptions/kb_generation.py
new file mode 100644
index 0000000..c40e72a
--- /dev/null
+++ b/src/mkcv/core/exceptions/kb_generation.py
@@ -0,0 +1,17 @@
+"""Knowledge base generation errors."""
+
+from mkcv.core.exceptions.base import MkcvError
+
+
+class KBGenerationError(MkcvError):
+ """Error during knowledge base generation or update."""
+
+ def __init__(self, message: str) -> None:
+ super().__init__(message, exit_code=9)
+
+
+class DocumentReadError(MkcvError):
+ """Failed to read or parse a source document."""
+
+ def __init__(self, message: str) -> None:
+ super().__init__(message, exit_code=9)
diff --git a/src/mkcv/core/models/__init__.py b/src/mkcv/core/models/__init__.py
index ae7e2ff..3fd3938 100644
--- a/src/mkcv/core/models/__init__.py
+++ b/src/mkcv/core/models/__init__.py
@@ -9,12 +9,14 @@
from mkcv.core.models.ats_check import ATSCheck
from mkcv.core.models.bullet_review import BulletReview
from mkcv.core.models.compensation import Compensation
+from mkcv.core.models.document_content import DocumentContent
from mkcv.core.models.earlier_experience_section import EarlierExperienceSection
from mkcv.core.models.experience_entry import ExperienceEntry
from mkcv.core.models.experience_selection import ExperienceSelection
from mkcv.core.models.jd_analysis import JDAnalysis
from mkcv.core.models.jd_document import JDDocument
from mkcv.core.models.jd_frontmatter import JDFrontmatter
+from mkcv.core.models.kb_generation_result import KBGenerationResult
from mkcv.core.models.kb_validation import KBValidationResult
from mkcv.core.models.keyword_coverage import KeywordCoverage
from mkcv.core.models.languages_section import LanguagesSection
@@ -51,12 +53,14 @@
"ApplicationMetadata",
"BulletReview",
"Compensation",
+ "DocumentContent",
"EarlierExperienceSection",
"ExperienceEntry",
"ExperienceSelection",
"JDAnalysis",
"JDDocument",
"JDFrontmatter",
+ "KBGenerationResult",
"KBValidationResult",
"KeywordCoverage",
"LanguagesSection",
diff --git a/src/mkcv/core/models/document_content.py b/src/mkcv/core/models/document_content.py
new file mode 100644
index 0000000..002609c
--- /dev/null
+++ b/src/mkcv/core/models/document_content.py
@@ -0,0 +1,23 @@
+"""Extracted document content model."""
+
+from pathlib import Path
+
+from pydantic import BaseModel, Field
+
+
+class DocumentContent(BaseModel):
+ """Content extracted from a source document.
+
+ Attributes:
+ text: The extracted plain-text content.
+ source_path: Path to the original file.
+ format: File format identifier, e.g. "pdf", "markdown", "docx".
+ char_count: Number of characters in the extracted text.
+ metadata: Optional key-value metadata from the document.
+ """
+
+ text: str
+ source_path: Path
+ format: str
+ char_count: int = Field(ge=0)
+ metadata: dict[str, str] = Field(default_factory=dict)
diff --git a/src/mkcv/core/models/kb_generation_result.py b/src/mkcv/core/models/kb_generation_result.py
new file mode 100644
index 0000000..2cd0e3f
--- /dev/null
+++ b/src/mkcv/core/models/kb_generation_result.py
@@ -0,0 +1,23 @@
+"""Knowledge base generation result model."""
+
+from pathlib import Path
+
+from pydantic import BaseModel, Field
+
+from mkcv.core.models.document_content import DocumentContent
+
+
+class KBGenerationResult(BaseModel):
+ """Result of generating a knowledge base from source documents.
+
+ Attributes:
+ kb_text: The generated knowledge base Markdown content.
+ source_documents: Documents that were used as input.
+ output_path: Path where the KB file was written, if applicable.
+ validation_warnings: Non-blocking issues found during validation.
+ """
+
+ kb_text: str
+ source_documents: list[DocumentContent]
+ output_path: Path | None = None
+ validation_warnings: list[str] = Field(default_factory=list)
diff --git a/src/mkcv/core/ports/__init__.py b/src/mkcv/core/ports/__init__.py
index 6e7146d..e08e8a5 100644
--- a/src/mkcv/core/ports/__init__.py
+++ b/src/mkcv/core/ports/__init__.py
@@ -8,6 +8,7 @@
"""
from mkcv.core.ports.artifacts import ArtifactStorePort
+from mkcv.core.ports.document_reader import DocumentReaderPort
from mkcv.core.ports.llm import LLMPort
from mkcv.core.ports.prompts import PromptLoaderPort
from mkcv.core.ports.renderer import RenderedOutput, RendererPort
@@ -15,6 +16,7 @@
__all__ = [
"ArtifactStorePort",
+ "DocumentReaderPort",
"LLMPort",
"PromptLoaderPort",
"RenderedOutput",
diff --git a/src/mkcv/core/ports/document_reader.py b/src/mkcv/core/ports/document_reader.py
new file mode 100644
index 0000000..acc7557
--- /dev/null
+++ b/src/mkcv/core/ports/document_reader.py
@@ -0,0 +1,36 @@
+"""Port interface for reading text from multi-format documents."""
+
+from pathlib import Path
+from typing import Protocol, runtime_checkable
+
+from mkcv.core.models.document_content import DocumentContent
+
+
+@runtime_checkable
+class DocumentReaderPort(Protocol):
+ """Interface for reading text content from various document formats.
+
+ Implementations: MultiFormatDocumentReader.
+ """
+
+ def read_file(self, path: Path) -> DocumentContent:
+ """Read text content from a single document file.
+
+ Args:
+ path: Path to the document file.
+
+ Returns:
+ Extracted document content with metadata.
+
+ Raises:
+ DocumentReadError: If the file cannot be read or parsed.
+ """
+ ...
+
+ def supported_extensions(self) -> set[str]:
+ """Return the set of file extensions this reader supports.
+
+ Returns:
+ Set of lowercase extensions including the dot, e.g. {".pdf", ".md"}.
+ """
+ ...
diff --git a/src/mkcv/core/services/__init__.py b/src/mkcv/core/services/__init__.py
index a624d0d..a507c80 100644
--- a/src/mkcv/core/services/__init__.py
+++ b/src/mkcv/core/services/__init__.py
@@ -6,6 +6,7 @@
from mkcv.core.services import PipelineService, RenderService, ...
"""
+from mkcv.core.services.kb_generation_service import KBGenerationService
from mkcv.core.services.kb_validator import validate_kb
from mkcv.core.services.pipeline import PipelineService
from mkcv.core.services.regeneration import RegenerationService
@@ -14,6 +15,7 @@
from mkcv.core.services.workspace import WorkspaceService
__all__ = [
+ "KBGenerationService",
"PipelineService",
"RegenerationService",
"RenderService",
diff --git a/src/mkcv/core/services/kb_generation_service.py b/src/mkcv/core/services/kb_generation_service.py
new file mode 100644
index 0000000..6ac2862
--- /dev/null
+++ b/src/mkcv/core/services/kb_generation_service.py
@@ -0,0 +1,614 @@
+"""Knowledge base generation service."""
+
+import asyncio
+import logging
+from collections.abc import Awaitable, Callable
+from pathlib import Path
+from typing import TypeVar
+
+from mkcv.core.exceptions.authentication import AuthenticationError
+from mkcv.core.exceptions.context_length import ContextLengthError
+from mkcv.core.exceptions.kb_generation import KBGenerationError
+from mkcv.core.exceptions.provider import ProviderError
+from mkcv.core.exceptions.rate_limit import RateLimitError
+from mkcv.core.exceptions.validation import ValidationError
+from mkcv.core.models.document_content import DocumentContent
+from mkcv.core.models.kb_generation_result import KBGenerationResult
+from mkcv.core.ports.document_reader import DocumentReaderPort
+from mkcv.core.ports.llm import LLMPort
+from mkcv.core.ports.prompts import PromptLoaderPort
+from mkcv.core.services.kb_validator import validate_kb
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_MODEL = "claude-sonnet-4-20250514"
+DEFAULT_MAX_TOKENS = 8192
+CHUNK_CHAR_THRESHOLD = 100_000
+
+_R = TypeVar("_R")
+
+
+class KBGenerationService:
+ """Generates and updates structured Markdown knowledge bases from source documents.
+
+ Takes source documents (PDF, Markdown, DOCX, HTML, TXT), reads them via
+ a DocumentReaderPort, synthesises content through an LLM, validates the
+ output, and writes the result to disk.
+
+ For large inputs exceeding the chunk threshold, documents are processed
+ in chunks and then merged into a single KB.
+ """
+
+ def __init__(
+ self,
+ document_reader: DocumentReaderPort,
+ llm: LLMPort,
+ prompts: PromptLoaderPort,
+ *,
+ model: str = DEFAULT_MODEL,
+ temperature: float = 0.3,
+ max_tokens: int = DEFAULT_MAX_TOKENS,
+ chunk_threshold: int = CHUNK_CHAR_THRESHOLD,
+ ) -> None:
+ self._reader = document_reader
+ self._llm = llm
+ self._prompts = prompts
+ self._model = model
+ self._temperature = temperature
+ self._max_tokens = max_tokens
+ self._chunk_threshold = chunk_threshold
+
+ # ------------------------------------------------------------------
+ # Public API
+ # ------------------------------------------------------------------
+
+ async def generate(
+ self,
+ sources: list[Path],
+ output: Path | None = None,
+ kb_name: str = "Career",
+ glob: str | None = None,
+ ) -> KBGenerationResult:
+ """Generate a knowledge base from source documents.
+
+ Reads all supported files from *sources* (files or directories),
+ renders the kb_generate prompt, calls the LLM, validates the
+ output, and optionally writes to *output*.
+
+ When total character count exceeds the chunk threshold, documents
+ are split into chunks, partial KBs are generated, and then merged
+ into a final result.
+
+ Args:
+ sources: File and/or directory paths containing source documents.
+ output: Path to write the resulting KB file. When ``None``, the
+ KB text is returned but not written to disk.
+ kb_name: Name used in the KB title heading (default "Career").
+ glob: Glob pattern for directory scanning. ``None`` uses the
+ adapter default (``**/*``).
+
+ Returns:
+ KBGenerationResult with the generated text, source metadata,
+ output path, and any validation warnings.
+
+ Raises:
+ KBGenerationError: If no documents are found or the LLM call fails.
+ DocumentReadError: If individual files cannot be read.
+ """
+ documents = self._read_documents(sources, glob=glob)
+
+ if not documents:
+ supported = ", ".join(sorted(self._reader.supported_extensions()))
+ raise KBGenerationError(
+ "No supported documents found in the "
+ f"provided sources. Supported formats: {supported}"
+ )
+
+ logger.info(
+ "Generating KB '%s' from %d document(s) (%d total chars)",
+ kb_name,
+ len(documents),
+ sum(d.char_count for d in documents),
+ )
+
+ total_chars = sum(d.char_count for d in documents)
+
+ if total_chars > self._chunk_threshold:
+ kb_text = await self._generate_chunked(documents, kb_name=kb_name)
+ else:
+ kb_text = await self._generate_single(documents, kb_name=kb_name)
+
+ # Strip markdown code fences if the LLM wraps output
+ kb_text = _strip_code_fences(kb_text)
+
+ # Validate the generated KB
+ validation = validate_kb(kb_text)
+ warnings = validation.warnings
+
+ # Write output if path provided
+ output_path: Path | None = None
+ if output is not None:
+ output_path = self._write_output(kb_text, output)
+
+ logger.info(
+ "KB generation complete: %d chars, %d warnings",
+ len(kb_text),
+ len(warnings),
+ )
+
+ return KBGenerationResult(
+ kb_text=kb_text,
+ source_documents=documents,
+ output_path=output_path,
+ validation_warnings=warnings,
+ )
+
+ async def update(
+ self,
+ existing_kb_path: Path,
+ sources: list[Path],
+ glob: str | None = None,
+ ) -> KBGenerationResult:
+ """Update an existing knowledge base with new source documents.
+
+ Reads the existing KB and new documents, renders the kb_update
+ prompt, calls the LLM, validates the result, and writes the
+ updated KB back to the existing path.
+
+ Args:
+ existing_kb_path: Path to the existing KB Markdown file.
+ sources: File and/or directory paths containing new documents.
+ glob: Glob pattern for directory scanning. ``None`` uses the
+ adapter default (``**/*``).
+
+ Returns:
+ KBGenerationResult with the updated text, source metadata,
+ output path, and any validation warnings.
+
+ Raises:
+ KBGenerationError: If the existing KB cannot be read, no new
+ documents are found, or the LLM call fails.
+ DocumentReadError: If individual files cannot be read.
+ """
+ if not existing_kb_path.is_file():
+ raise KBGenerationError(
+ f"Existing knowledge base not found: {existing_kb_path}"
+ )
+
+ try:
+ existing_kb = existing_kb_path.read_text(encoding="utf-8")
+ except (OSError, UnicodeDecodeError) as exc:
+ raise KBGenerationError(
+ f"Cannot read existing knowledge base '{existing_kb_path}': {exc}"
+ ) from exc
+
+ documents = self._read_documents(sources, glob=glob)
+
+ if not documents:
+ supported = ", ".join(sorted(self._reader.supported_extensions()))
+ raise KBGenerationError(
+ "No supported documents found in the "
+ f"provided sources. Supported formats: {supported}"
+ )
+
+ logger.info(
+ "Updating KB '%s' with %d new document(s) (%d total chars)",
+ existing_kb_path.name,
+ len(documents),
+ sum(d.char_count for d in documents),
+ )
+
+ document_texts = self._build_document_texts(documents)
+
+ prompt = self._prompts.render(
+ "kb_update.j2",
+ {
+ "existing_kb": existing_kb,
+ "document_texts": document_texts,
+ },
+ )
+
+ kb_text = await self._call_llm(prompt)
+
+ # Strip markdown code fences if the LLM wraps output
+ kb_text = _strip_code_fences(kb_text)
+
+ # Validate the updated KB
+ validation = validate_kb(kb_text)
+ warnings = validation.warnings
+
+ # Write back to the existing path
+ output_path = self._write_output(kb_text, existing_kb_path)
+
+ logger.info(
+ "KB update complete: %d chars, %d warnings",
+ len(kb_text),
+ len(warnings),
+ )
+
+ return KBGenerationResult(
+ kb_text=kb_text,
+ source_documents=documents,
+ output_path=output_path,
+ validation_warnings=warnings,
+ )
+
+ # ------------------------------------------------------------------
+ # Document reading
+ # ------------------------------------------------------------------
+
+ def _read_documents(
+ self,
+ sources: list[Path],
+ *,
+ glob: str | None,
+ ) -> list[DocumentContent]:
+ """Read documents from source paths using the document reader.
+
+ Args:
+ sources: File and/or directory paths.
+ glob: Optional glob pattern for directory scanning.
+
+ Returns:
+ List of document contents, sorted by source path.
+
+ Raises:
+ DocumentReadError: If a file cannot be read.
+ """
+ # The adapter's read_sources handles both files and directories
+ # and supports the glob parameter for directory scanning.
+ # We need to call the port's read_file for each file individually
+ # since the port only defines read_file, not read_sources.
+ supported = self._reader.supported_extensions()
+ effective_glob = glob or "**/*"
+
+ discovered: set[Path] = set()
+ for p in sources:
+ resolved = p.resolve()
+ if resolved.is_file():
+ if resolved.suffix.lower() in supported:
+ discovered.add(resolved)
+ else:
+ logger.debug("Skipping unsupported file: %s", resolved)
+ elif resolved.is_dir():
+ for child in resolved.glob(effective_glob):
+ if child.is_file() and child.suffix.lower() in supported:
+ discovered.add(child)
+ else:
+ logger.warning("Path does not exist, skipping: %s", resolved)
+
+ results: list[DocumentContent] = []
+ for file_path in sorted(discovered):
+ logger.info("Reading %s", file_path)
+ results.append(self._reader.read_file(file_path))
+
+ return results
+
+ # ------------------------------------------------------------------
+ # LLM interaction
+ # ------------------------------------------------------------------
+
+ async def _generate_single(
+ self,
+ documents: list[DocumentContent],
+ *,
+ kb_name: str,
+ ) -> str:
+ """Generate a KB from all documents in a single LLM call.
+
+ Args:
+ documents: Source documents to include.
+ kb_name: Name for the KB title heading.
+
+ Returns:
+ Generated KB Markdown text.
+ """
+ document_texts = self._build_document_texts(documents)
+
+ prompt = self._prompts.render(
+ "kb_generate.j2",
+ {
+ "document_texts": document_texts,
+ "kb_name": kb_name,
+ },
+ )
+
+ return await self._call_llm(prompt)
+
+ async def _generate_chunked(
+ self,
+ documents: list[DocumentContent],
+ *,
+ kb_name: str,
+ ) -> str:
+ """Generate a KB by processing documents in chunks, then merging.
+
+ Splits documents into groups that each fit under the chunk
+ threshold, generates a partial KB for each chunk, then merges
+ all partial KBs into a final result.
+
+ Args:
+ documents: Source documents to include.
+ kb_name: Name for the KB title heading.
+
+ Returns:
+ Merged KB Markdown text.
+ """
+ chunks = self._split_into_chunks(documents)
+
+ logger.info(
+ "Large input (%d chars) — splitting into %d chunks",
+ sum(d.char_count for d in documents),
+ len(chunks),
+ )
+
+ # Generate partial KBs for each chunk
+ partial_kbs: list[str] = []
+ for i, chunk in enumerate(chunks, start=1):
+ logger.info(
+ "Processing chunk %d/%d (%d documents, %d chars)",
+ i,
+ len(chunks),
+ len(chunk),
+ sum(d.char_count for d in chunk),
+ )
+ partial = await self._generate_single(chunk, kb_name=kb_name)
+ partial_kbs.append(partial)
+
+ # If only one chunk, no merge needed
+ if len(partial_kbs) == 1:
+ return partial_kbs[0]
+
+ # Merge all partial KBs by treating each as a "document"
+ return await self._merge_partial_kbs(partial_kbs, kb_name=kb_name)
+
+ async def _merge_partial_kbs(
+ self,
+ partial_kbs: list[str],
+ *,
+ kb_name: str,
+ ) -> str:
+ """Merge multiple partial KBs into a single unified KB.
+
+ Uses the update prompt iteratively: starts with the first partial
+ KB and merges each subsequent one into it.
+
+ Args:
+ partial_kbs: List of partial KB Markdown texts.
+ kb_name: Name for the KB title heading.
+
+ Returns:
+ Merged KB Markdown text.
+ """
+ merged = partial_kbs[0]
+
+ for i, partial in enumerate(partial_kbs[1:], start=2):
+ logger.info("Merging partial KB %d/%d", i, len(partial_kbs))
+
+ document_texts = [
+ {"filename": f"partial_kb_{i}.md", "text": partial},
+ ]
+
+ prompt = self._prompts.render(
+ "kb_update.j2",
+ {
+ "existing_kb": merged,
+ "document_texts": document_texts,
+ },
+ )
+
+ merged = await self._call_llm(prompt)
+
+ return merged
+
+ async def _call_llm(self, prompt: str) -> str:
+ """Send a prompt to the LLM and return the text response.
+
+ Wraps the LLM call with retry logic for transient failures.
+
+ Args:
+ prompt: The rendered prompt text.
+
+ Returns:
+ LLM response text.
+
+ Raises:
+ KBGenerationError: After all retries are exhausted.
+ """
+ messages: list[dict[str, str]] = [
+ {"role": "user", "content": prompt},
+ ]
+
+ return await self._call_with_retry(
+ lambda: self._do_complete(messages),
+ stage_name="kb_generation",
+ )
+
+ async def _do_complete(self, messages: list[dict[str, str]]) -> str:
+ """Execute a single LLM completion call.
+
+ Args:
+ messages: Chat messages to send.
+
+ Returns:
+ LLM response text.
+
+ Raises:
+ KBGenerationError: On unexpected errors.
+ """
+ try:
+ return await self._llm.complete(
+ messages,
+ model=self._model,
+ temperature=self._temperature,
+ max_tokens=self._max_tokens,
+ )
+ except KBGenerationError:
+ raise
+ except (ValidationError, ValueError):
+ raise
+ except (
+ RateLimitError,
+ AuthenticationError,
+ ContextLengthError,
+ ProviderError,
+ ):
+ raise
+ except Exception as exc:
+ raise KBGenerationError(f"KB generation LLM call failed: {exc}") from exc
+
+ async def _call_with_retry(
+ self,
+ coro_fn: Callable[[], Awaitable[_R]],
+ *,
+ stage_name: str,
+ max_retries: int = 3,
+ ) -> _R:
+ """Call an async function with retries on transient failures.
+
+ Retries on ``ValidationError`` and ``ValueError`` (transient LLM
+ output quality issues). Non-retryable provider errors are raised
+ immediately.
+
+ Args:
+ coro_fn: Zero-argument async callable that produces the result.
+ stage_name: Human-readable name for log messages.
+ max_retries: Maximum number of attempts (default 3).
+
+ Returns:
+ The value produced by *coro_fn*.
+
+ Raises:
+ KBGenerationError: After all retries are exhausted.
+ RateLimitError: Immediately on rate-limit errors.
+ AuthenticationError: Immediately on auth errors.
+ ContextLengthError: Immediately on context-length errors.
+ ProviderError: Immediately on other provider errors.
+ """
+ last_error: Exception | None = None
+
+ for attempt in range(1, max_retries + 1):
+ try:
+ return await coro_fn()
+ except (
+ RateLimitError,
+ AuthenticationError,
+ ContextLengthError,
+ ProviderError,
+ ):
+ raise
+ except (ValidationError, ValueError) as exc:
+ last_error = exc
+ if attempt < max_retries:
+ logger.warning(
+ "%s attempt %d/%d failed: %s. Retrying...",
+ stage_name,
+ attempt,
+ max_retries,
+ exc,
+ )
+ await asyncio.sleep(1.0 * attempt)
+
+ raise KBGenerationError(
+ f"{stage_name} failed after {max_retries} attempts: {last_error}"
+ ) from last_error
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _build_document_texts(
+ documents: list[DocumentContent],
+ ) -> list[dict[str, str]]:
+ """Build the document_texts list expected by prompt templates.
+
+ Each entry has ``filename`` and ``text`` keys matching the
+ Jinja2 template variables in ``kb_generate.j2`` and ``kb_update.j2``.
+
+ Args:
+ documents: Source document contents.
+
+ Returns:
+ List of dicts with ``filename`` and ``text`` keys.
+ """
+ return [
+ {
+ "filename": doc.source_path.name,
+ "text": doc.text,
+ }
+ for doc in documents
+ ]
+
+ def _split_into_chunks(
+ self,
+ documents: list[DocumentContent],
+ ) -> list[list[DocumentContent]]:
+ """Split documents into chunks that each fit under the threshold.
+
+ Each chunk tries to stay under ``self._chunk_threshold`` total
+ characters. A single document that exceeds the threshold gets
+ its own chunk.
+
+ Args:
+ documents: Source documents sorted by path.
+
+ Returns:
+ List of document groups (chunks).
+ """
+ chunks: list[list[DocumentContent]] = []
+ current_chunk: list[DocumentContent] = []
+ current_chars = 0
+
+ for doc in documents:
+ if current_chunk and current_chars + doc.char_count > self._chunk_threshold:
+ chunks.append(current_chunk)
+ current_chunk = []
+ current_chars = 0
+
+ current_chunk.append(doc)
+ current_chars += doc.char_count
+
+ if current_chunk:
+ chunks.append(current_chunk)
+
+ return chunks
+
+ @staticmethod
+ def _write_output(kb_text: str, output_path: Path) -> Path:
+ """Write generated KB text to a file.
+
+ Creates parent directories if they do not exist.
+
+ Args:
+ kb_text: The KB Markdown content.
+ output_path: Destination file path.
+
+ Returns:
+ The resolved output path.
+
+ Raises:
+ KBGenerationError: If writing fails.
+ """
+ try:
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(kb_text, encoding="utf-8")
+ except OSError as exc:
+ raise KBGenerationError(
+ f"Failed to write knowledge base to '{output_path}': {exc}"
+ ) from exc
+
+ logger.info("Knowledge base written to %s", output_path)
+ return output_path.resolve()
+
+
+def _strip_code_fences(text: str) -> str:
+ """Remove markdown code fences wrapping content."""
+ stripped = text.strip()
+ if stripped.startswith("```"):
+ # Remove opening fence (```markdown or ```)
+ first_newline = stripped.index("\n")
+ stripped = stripped[first_newline + 1 :]
+ if stripped.endswith("```"):
+ stripped = stripped[:-3].rstrip()
+ return stripped
diff --git a/src/mkcv/prompts/kb_generate.j2 b/src/mkcv/prompts/kb_generate.j2
new file mode 100644
index 0000000..554cdeb
--- /dev/null
+++ b/src/mkcv/prompts/kb_generate.j2
@@ -0,0 +1,161 @@
+{# Generate a structured knowledge base from source documents.
+
+Context variables:
+ - document_texts: list[dict] — extracted documents, each with 'filename' and 'text'
+ - kb_name: str — name for the KB title heading (default "Career")
+
+Output: Markdown matching the mkcv knowledge base format.
+#}
+You are an expert career knowledge base compiler. Your job is to synthesize
+source documents into a single, comprehensive, structured Markdown knowledge
+base that can be used by mkcv to generate tailored resumes.
+
+## Source Documents
+
+{% for doc in document_texts %}
+### Document: {{ doc.filename }}
+
+{{ doc.text }}
+
+{% endfor %}
+
+## Instructions
+
+Synthesize ALL source documents into a single structured Markdown knowledge
+base. Follow the exact format specification below.
+
+### CRITICAL: LOSSLESS Synthesis
+
+You MUST preserve ALL information from the source documents. This is the
+candidate's career record — every detail matters:
+
+- **Every role, position, and employer** — include all of them, even short stints
+- **Every bullet point, achievement, and responsibility** — do not summarize away detail
+- **Every metric, number, and quantified result** — copy exactly as stated
+- **Every technology, tool, and skill mentioned** — include all of them
+- **Every date, duration, and time period** — preserve exactly
+- **Every education entry, certification, and credential** — include all
+- **Every project, publication, and award** — include all
+- **Contact information** — name, email, phone, location, URLs, social profiles
+
+Do NOT:
+- Summarize multiple bullets into one
+- Drop roles you consider "less important"
+- Omit technologies that seem outdated
+- Round or approximate numbers
+- Invent or infer information not present in the sources
+- Skip any section of the source documents
+
+When the same information appears in multiple documents, merge intelligently:
+- Use the most detailed version of overlapping content
+- Combine non-overlapping details from each source
+- For conflicting facts (e.g., different dates), prefer the source that appears
+ more authoritative or recent, and note the discrepancy in a comment
+
+### Knowledge Base Format
+
+Produce Markdown with these sections in order. Use the exact heading structure
+shown. If a section has no data in the sources, omit it entirely — do NOT
+include empty sections or placeholder text.
+
+```
+# {{ kb_name | default("Career") }} -- Career Knowledge Base
+
+## Personal Information
+
+| Field | Value |
+|----------|--------------------|
+| Name | Full Name |
+| Email | email@example.com |
+| Phone | +1 234 567 8900 |
+| Location | City, State/Country|
+| LinkedIn | linkedin.com/in/… |
+| GitHub | github.com/… |
+| Website | https://… |
+
+## Languages
+
+- English (native)
+- Spanish (proficient)
+
+## Professional Summary
+
+2-4 sentences summarizing the candidate's career arc, core expertise,
+years of experience, and key differentiators. Write in third person
+or neutral tone. Base this ENTIRELY on facts from the source documents.
+
+## Technical Skills -- Master List
+
+### Category Name (e.g., Programming Languages)
+- Skill (years if known) -- notes if available
+- Another Skill
+
+### Another Category (e.g., Cloud & Infrastructure)
+- Skill
+- Another Skill
+
+(Group skills into logical categories. Include ALL technologies
+mentioned across all source documents.)
+
+## Career History
+
+### Company Name — Job Title
+**Period:** Month Year - Month Year (or "present")
+**Location:** City, State/Country (if known)
+
+- Achievement or responsibility using XYZ formula where possible
+- Another bullet point — preserve metrics exactly as stated
+- Continue with ALL bullets from the source documents for this role
+
+(Repeat for EVERY role. Order reverse-chronologically — most recent first.
+Each role should have its own ### heading.)
+
+## Education
+
+### Institution Name — Degree, Field of Study
+**Period:** Year - Year
+**Location:** City, State/Country (if known)
+
+- Notable coursework, honors, GPA, or achievements (if mentioned)
+
+## Projects
+
+### Project Name
+- Description and details
+- Technologies used
+- Outcomes or impact
+
+## Certifications
+
+- Certification Name — Issuing Body (Year)
+```
+
+### Formatting Rules
+
+1. **Headings** — Use `#` for the title, `##` for major sections, `###` for
+ subsections (individual roles, education entries, projects).
+2. **Tables** — Use Markdown tables only for Personal Information.
+3. **Bullets** — Use `-` for all bullet lists. Indent with 2 spaces for
+ sub-bullets.
+4. **Dates** — Preserve the exact format from the source. If only years are
+ available, use years. If month+year, use "Month Year" or "YYYY-MM".
+5. **Bold** — Use `**bold**` for field labels like Period, Location.
+6. **Comments** — Use `` for any notes about conflicting
+ information between sources.
+
+### Content Quality Rules
+
+1. **XYZ-formula bullets** — Where the source provides enough detail, structure
+ bullets as: [Action verb] + [what was done] + [result/impact]. Do NOT
+ rewrite bullets that would lose specificity — preserve the original
+ phrasing if it is already detailed.
+2. **No fabrication** — Every fact must trace to a source document. If a source
+ says "improved performance," write "improved performance" — do NOT add
+ percentages or metrics that are not in the source.
+3. **Deduplication** — When multiple sources describe the same role or
+ achievement, merge into one entry with the combined detail from all sources.
+ Do not create duplicate role entries.
+4. **Completeness over polish** — This is a knowledge base, not a resume.
+ Include everything. The AI pipeline will select and tailor later.
+
+Respond with the Markdown knowledge base only. No commentary, no code fences.
diff --git a/src/mkcv/prompts/kb_update.j2 b/src/mkcv/prompts/kb_update.j2
new file mode 100644
index 0000000..de2db2c
--- /dev/null
+++ b/src/mkcv/prompts/kb_update.j2
@@ -0,0 +1,115 @@
+{# Update an existing knowledge base with new source documents.
+
+Context variables:
+ - existing_kb: str — the current knowledge base Markdown text
+ - document_texts: list[dict] — new documents, each with 'filename' and 'text'
+
+Output: Markdown matching the mkcv knowledge base format (updated).
+#}
+You are an expert career knowledge base editor. Your job is to merge new
+source documents into an existing knowledge base, preserving all existing
+content while incorporating new information.
+
+## Existing Knowledge Base
+
+{{ existing_kb }}
+
+## New Source Documents
+
+{% for doc in document_texts %}
+### Document: {{ doc.filename }}
+
+{{ doc.text }}
+
+{% endfor %}
+
+## Instructions
+
+Merge the new source documents into the existing knowledge base. The result
+must be a single, complete, updated Markdown knowledge base.
+
+### CRITICAL: Preserve ALL Existing Content
+
+The existing knowledge base is the candidate's verified career record.
+You MUST preserve every piece of information already in it:
+
+- **Every existing role, bullet, metric, and detail** — keep them all
+- **Every existing skill, technology, and certification** — keep them all
+- **Every existing section and its structure** — maintain the format
+- **Personal information** — update only if the new source has newer/better data
+
+Do NOT:
+- Remove or summarize existing bullets to "make room" for new content
+- Drop existing roles, skills, or sections
+- Rewrite existing content unless the new source provides a clear correction
+- Change the overall structure or heading hierarchy
+
+### Merge Strategy
+
+For each piece of information in the new documents, apply this logic:
+
+1. **New role not in the KB** — Add it in the correct chronological position
+ under Career History. Include all bullets, dates, and details from the source.
+
+2. **Existing role with new details** — Add new bullets or details to the
+ existing role entry. Do not duplicate bullets that convey the same achievement.
+ Place new bullets in a logical position among existing ones.
+
+3. **New skills or technologies** — Add to the appropriate category under
+ Technical Skills. If no matching category exists, create a new subsection.
+
+4. **Updated personal information** — If the new source has a more recent
+ email, phone, location, or URL, update the field. Keep existing fields
+ that are not contradicted.
+
+5. **New education, certifications, or projects** — Add to the appropriate
+ section. Create the section if it does not exist yet.
+
+6. **Conflicting information** — When new documents contradict the existing KB:
+ - Prefer the **newer source** for factual data (dates, titles, locations)
+ - Preserve both versions if the conflict is ambiguous, with a
+ `` comment noting the discrepancy
+ - Never silently discard the existing version
+
+7. **Professional Summary** — Update only if the new documents reveal a
+ significant change (e.g., new seniority level, new domain expertise, or
+ a career pivot). Otherwise, keep the existing summary unchanged.
+
+### Deduplication Rules
+
+- **Same role at same company** — Merge bullets; do not create a duplicate entry
+- **Same skill listed in multiple places** — Keep one instance in the most
+ appropriate category
+- **Same certification or education** — Keep one instance with the most detail
+- **Similar but not identical bullets** — Keep both if they describe different
+ aspects of the same work; merge if they are truly redundant
+
+### Output Format
+
+Produce the complete updated knowledge base in the same Markdown format as
+the existing KB. Maintain the same heading structure:
+
+- `#` for the title
+- `##` for major sections (Personal Information, Professional Summary,
+ Technical Skills, Career History, Education, Projects, Certifications)
+- `###` for subsections (individual roles, skill categories, education entries)
+- `-` for bullet lists
+- Markdown tables for Personal Information
+- `**bold**` for field labels (Period, Location)
+
+Include ALL content — both existing and new. The output replaces the
+existing KB file entirely, so nothing should be omitted.
+
+### Content Quality Rules
+
+1. **No fabrication** — Every fact must trace to either the existing KB or a
+ new source document. Do NOT invent details.
+2. **Preserve metrics exactly** — Copy numbers, percentages, and measurements
+ verbatim from their source.
+3. **Completeness over polish** — This is a knowledge base, not a resume.
+ Include everything. The AI pipeline will select and tailor later.
+4. **Chronological order** — Career History entries should be reverse-
+ chronological (most recent first).
+
+Respond with the complete updated Markdown knowledge base only. No commentary,
+no code fences.
diff --git a/tests/test_adapters/test_filesystem/test_document_reader.py b/tests/test_adapters/test_filesystem/test_document_reader.py
new file mode 100644
index 0000000..d940b3a
--- /dev/null
+++ b/tests/test_adapters/test_filesystem/test_document_reader.py
@@ -0,0 +1,360 @@
+"""Tests for MultiFormatDocumentReader adapter."""
+
+import warnings
+from pathlib import Path
+
+import pytest
+
+from mkcv.adapters.filesystem.document_reader import MultiFormatDocumentReader
+from mkcv.core.exceptions.kb_generation import DocumentReadError
+from mkcv.core.models.document_content import DocumentContent
+
+
+@pytest.fixture
+def reader() -> MultiFormatDocumentReader:
+ """Create a fresh reader instance."""
+ return MultiFormatDocumentReader()
+
+
+# ------------------------------------------------------------------
+# supported_extensions
+# ------------------------------------------------------------------
+
+
+class TestSupportedExtensions:
+ """Tests for supported_extensions()."""
+
+ def test_returns_set(self, reader: MultiFormatDocumentReader) -> None:
+ exts = reader.supported_extensions()
+ assert isinstance(exts, set)
+
+ def test_includes_pdf(self, reader: MultiFormatDocumentReader) -> None:
+ assert ".pdf" in reader.supported_extensions()
+
+ def test_includes_markdown(self, reader: MultiFormatDocumentReader) -> None:
+ exts = reader.supported_extensions()
+ assert ".md" in exts
+ assert ".markdown" in exts
+
+ def test_includes_text(self, reader: MultiFormatDocumentReader) -> None:
+ exts = reader.supported_extensions()
+ assert ".txt" in exts
+ assert ".text" in exts
+
+ def test_includes_docx(self, reader: MultiFormatDocumentReader) -> None:
+ assert ".docx" in reader.supported_extensions()
+
+ def test_includes_html(self, reader: MultiFormatDocumentReader) -> None:
+ exts = reader.supported_extensions()
+ assert ".html" in exts
+ assert ".htm" in exts
+
+
+# ------------------------------------------------------------------
+# read_file: text files
+# ------------------------------------------------------------------
+
+
+class TestReadText:
+ """Tests for reading plain text files."""
+
+ def test_reads_txt_file(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ f = tmp_path / "notes.txt"
+ f.write_text("Hello world", encoding="utf-8")
+ result = reader.read_file(f)
+ assert result.text == "Hello world"
+ assert result.format == "text"
+ assert result.char_count == 11
+
+ def test_reads_text_extension(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ f = tmp_path / "notes.text"
+ f.write_text("Alt extension", encoding="utf-8")
+ result = reader.read_file(f)
+ assert result.format == "text"
+ assert result.text == "Alt extension"
+
+
+# ------------------------------------------------------------------
+# read_file: markdown files
+# ------------------------------------------------------------------
+
+
+class TestReadMarkdown:
+ """Tests for reading Markdown files."""
+
+ def test_reads_md_file(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ f = tmp_path / "resume.md"
+ f.write_text("# Resume\n\nJane Doe", encoding="utf-8")
+ result = reader.read_file(f)
+ assert result.format == "markdown"
+ assert "Jane Doe" in result.text
+
+ def test_reads_markdown_extension(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ f = tmp_path / "resume.markdown"
+ f.write_text("# Resume", encoding="utf-8")
+ result = reader.read_file(f)
+ assert result.format == "markdown"
+
+
+# ------------------------------------------------------------------
+# read_file: HTML files
+# ------------------------------------------------------------------
+
+
+class TestReadHTML:
+ """Tests for reading HTML files."""
+
+ def test_reads_html_file(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ html = (
+ "
My CVEngineer
"
+ )
+ f = tmp_path / "profile.html"
+ f.write_text(html, encoding="utf-8")
+ result = reader.read_file(f)
+ assert result.format == "html"
+ assert "Engineer" in result.text
+
+ def test_html_extracts_title_metadata(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ html = "My CVText"
+ f = tmp_path / "profile.html"
+ f.write_text(html, encoding="utf-8")
+ result = reader.read_file(f)
+ assert result.metadata.get("title") == "My CV"
+
+ def test_reads_htm_extension(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ f = tmp_path / "page.htm"
+ f.write_text("Hello", encoding="utf-8")
+ result = reader.read_file(f)
+ assert result.format == "html"
+
+
+# ------------------------------------------------------------------
+# read_file: DOCX files
+# ------------------------------------------------------------------
+
+
+class TestReadDocx:
+ """Tests for reading DOCX files."""
+
+ def test_reads_docx_file(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ """Test reading a real DOCX file created with python-docx."""
+ import docx as python_docx
+
+ f = tmp_path / "resume.docx"
+ doc = python_docx.Document()
+ doc.core_properties.title = "My Resume"
+ doc.core_properties.author = "Test Author"
+ doc.add_paragraph("Senior Software Engineer")
+ doc.add_paragraph("Built scalable systems")
+ doc.save(str(f))
+
+ result = reader.read_file(f)
+ assert result.format == "docx"
+ assert "Senior Software Engineer" in result.text
+ assert result.metadata.get("title") == "My Resume"
+ assert result.metadata.get("author") == "Test Author"
+
+
+# ------------------------------------------------------------------
+# read_file: PDF files
+# ------------------------------------------------------------------
+
+
+class TestReadPDF:
+ """Tests for reading PDF files."""
+
+ def test_reads_pdf_file(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ """Test reading a real PDF file created with pypdf."""
+ from pypdf import PdfWriter
+
+ f = tmp_path / "resume.pdf"
+ writer = PdfWriter()
+ writer.add_blank_page(width=612, height=792)
+ with open(f, "wb") as fp:
+ writer.write(fp)
+
+ # This PDF has no text, so it should trigger the image-only warning
+ with warnings.catch_warnings(record=True) as w:
+ warnings.simplefilter("always")
+ result = reader.read_file(f)
+ assert any("image-only" in str(warning.message) for warning in w)
+
+ assert result.format == "pdf"
+
+ def test_corrupted_pdf_raises_document_read_error(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ f = tmp_path / "bad.pdf"
+ f.write_bytes(b"not a pdf file at all")
+ with pytest.raises(DocumentReadError, match="Cannot read PDF"):
+ reader.read_file(f)
+
+
+# ------------------------------------------------------------------
+# read_file: unsupported formats
+# ------------------------------------------------------------------
+
+
+class TestReadUnsupported:
+ """Tests for unsupported file formats."""
+
+ def test_unsupported_extension_raises(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ f = tmp_path / "data.csv"
+ f.write_text("a,b,c", encoding="utf-8")
+ with pytest.raises(DocumentReadError, match="Unsupported file format"):
+ reader.read_file(f)
+
+ def test_nonexistent_file_raises(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ f = tmp_path / "ghost.txt"
+ with pytest.raises(DocumentReadError, match="File not found"):
+ reader.read_file(f)
+
+
+# ------------------------------------------------------------------
+# read_file: returns DocumentContent
+# ------------------------------------------------------------------
+
+
+class TestReadFileReturnType:
+ """Tests that read_file returns properly structured DocumentContent."""
+
+ def test_returns_document_content(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ f = tmp_path / "test.txt"
+ f.write_text("content", encoding="utf-8")
+ result = reader.read_file(f)
+ assert isinstance(result, DocumentContent)
+
+ def test_source_path_is_set(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ f = tmp_path / "test.txt"
+ f.write_text("content", encoding="utf-8")
+ result = reader.read_file(f)
+ assert result.source_path == f
+
+
+# ------------------------------------------------------------------
+# read_sources: files, directories, and globs
+# ------------------------------------------------------------------
+
+
+class TestReadSources:
+ """Tests for read_sources() directory scanning."""
+
+ def test_reads_single_file(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ f = tmp_path / "test.txt"
+ f.write_text("hello", encoding="utf-8")
+ results = reader.read_sources([f])
+ assert len(results) == 1
+ assert results[0].text == "hello"
+
+ def test_reads_multiple_files(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ f1 = tmp_path / "a.txt"
+ f2 = tmp_path / "b.md"
+ f1.write_text("file a", encoding="utf-8")
+ f2.write_text("file b", encoding="utf-8")
+ results = reader.read_sources([f1, f2])
+ assert len(results) == 2
+
+ def test_reads_directory(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ subdir = tmp_path / "docs"
+ subdir.mkdir()
+ (subdir / "a.txt").write_text("aaa", encoding="utf-8")
+ (subdir / "b.md").write_text("bbb", encoding="utf-8")
+ results = reader.read_sources([subdir])
+ assert len(results) == 2
+
+ def test_directory_with_glob_filter(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ subdir = tmp_path / "docs"
+ subdir.mkdir()
+ (subdir / "a.txt").write_text("aaa", encoding="utf-8")
+ (subdir / "b.md").write_text("bbb", encoding="utf-8")
+ results = reader.read_sources([subdir], glob="**/*.md")
+ assert len(results) == 1
+ assert results[0].format == "markdown"
+
+ def test_skips_unsupported_files_in_directory(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ subdir = tmp_path / "docs"
+ subdir.mkdir()
+ (subdir / "a.txt").write_text("aaa", encoding="utf-8")
+ (subdir / "b.csv").write_text("x,y,z", encoding="utf-8")
+ results = reader.read_sources([subdir])
+ assert len(results) == 1
+
+ def test_skips_unsupported_standalone_files(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ f = tmp_path / "data.csv"
+ f.write_text("x,y,z", encoding="utf-8")
+ results = reader.read_sources([f])
+ assert len(results) == 0
+
+ def test_skips_nonexistent_paths(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ ghost = tmp_path / "nonexistent"
+ results = reader.read_sources([ghost])
+ assert len(results) == 0
+
+ def test_results_sorted_by_path(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ (tmp_path / "c.txt").write_text("c", encoding="utf-8")
+ (tmp_path / "a.txt").write_text("a", encoding="utf-8")
+ (tmp_path / "b.txt").write_text("b", encoding="utf-8")
+ results = reader.read_sources([tmp_path])
+ names = [r.source_path.name for r in results]
+ assert names == sorted(names)
+
+ def test_deduplicates_files(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ """When the same file is passed both directly and via directory."""
+ f = tmp_path / "test.txt"
+ f.write_text("hello", encoding="utf-8")
+ results = reader.read_sources([f, tmp_path])
+ assert len(results) == 1
+
+ def test_recursive_directory_scan(
+ self, reader: MultiFormatDocumentReader, tmp_path: Path
+ ) -> None:
+ nested = tmp_path / "a" / "b"
+ nested.mkdir(parents=True)
+ (nested / "deep.txt").write_text("deep content", encoding="utf-8")
+ results = reader.read_sources([tmp_path])
+ assert len(results) == 1
+ assert results[0].text == "deep content"
diff --git a/tests/test_adapters/test_llm/test_kb_factory.py b/tests/test_adapters/test_llm/test_kb_factory.py
new file mode 100644
index 0000000..8f6cc57
--- /dev/null
+++ b/tests/test_adapters/test_llm/test_kb_factory.py
@@ -0,0 +1,91 @@
+"""Tests for create_kb_generation_service factory function."""
+
+from unittest.mock import MagicMock
+
+from mkcv.adapters.factory import create_kb_generation_service
+from mkcv.adapters.llm.stub import StubLLMAdapter
+from mkcv.core.services.kb_generation_service import KBGenerationService
+
+
+def _make_config(
+ provider: str = "stub",
+ api_key: str | None = None,
+) -> MagicMock:
+ """Build a mock Configuration for KB generation tests."""
+ config = MagicMock()
+
+ if api_key is not None:
+ provider_section = MagicMock()
+ provider_section.api_key = api_key
+ providers = MagicMock()
+ setattr(providers, provider, provider_section)
+ config.providers = providers
+ else:
+ config.providers = None
+
+ # Simulate no [kb] config section
+ config.kb = None
+ config.in_workspace = False
+ config.workspace_root = None
+
+ return config
+
+
+class TestCreateKBGenerationService:
+ """Tests for the create_kb_generation_service factory."""
+
+ def test_returns_kb_generation_service(self) -> None:
+ config = _make_config()
+ service = create_kb_generation_service(config)
+ assert isinstance(service, KBGenerationService)
+
+ def test_uses_stub_when_no_api_key(self) -> None:
+ config = _make_config()
+ service = create_kb_generation_service(config)
+ # When no API key, anthropic falls back to stub
+ assert isinstance(service._llm, StubLLMAdapter) or hasattr(
+ service._llm, "_inner"
+ )
+
+ def test_provider_override(self) -> None:
+ config = _make_config()
+ service = create_kb_generation_service(config, provider_override="stub")
+ assert isinstance(service, KBGenerationService)
+ assert isinstance(service._llm, StubLLMAdapter)
+
+ def test_model_override(self) -> None:
+ config = _make_config()
+ service = create_kb_generation_service(config, model_override="test-model-xyz")
+ assert service._model == "test-model-xyz"
+
+ def test_default_model(self) -> None:
+ config = _make_config()
+ service = create_kb_generation_service(config)
+ assert "claude" in service._model or service._model != ""
+
+ def test_has_document_reader(self) -> None:
+ config = _make_config()
+ service = create_kb_generation_service(config)
+ assert service._reader is not None
+
+ def test_has_prompt_loader(self) -> None:
+ config = _make_config()
+ service = create_kb_generation_service(config)
+ assert service._prompts is not None
+
+ def test_kb_config_section_used(self) -> None:
+ """When [kb] section exists in config, its values are used."""
+ config = _make_config()
+ kb_section = MagicMock()
+ kb_section.provider = "stub"
+ kb_section.model = "custom-kb-model"
+ kb_section.temperature = 0.5
+ kb_section.max_tokens = 4096
+ kb_section.chunk_threshold = 50000
+ config.kb = kb_section
+
+ service = create_kb_generation_service(config)
+ assert service._model == "custom-kb-model"
+ assert service._temperature == 0.5
+ assert service._max_tokens == 4096
+ assert service._chunk_threshold == 50000
diff --git a/tests/test_cli/test_kb_command.py b/tests/test_cli/test_kb_command.py
new file mode 100644
index 0000000..278938d
--- /dev/null
+++ b/tests/test_cli/test_kb_command.py
@@ -0,0 +1,291 @@
+"""Tests for the mkcv kb CLI commands."""
+
+import subprocess
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from mkcv.core.exceptions.kb_generation import KBGenerationError
+from mkcv.core.models.document_content import DocumentContent
+from mkcv.core.models.kb_generation_result import KBGenerationResult
+
+_CMD = "mkcv.cli.commands.kb"
+
+
+def _make_doc(name: str = "test.txt") -> DocumentContent:
+ """Create a sample DocumentContent for test results."""
+ return DocumentContent(
+ text="sample text",
+ source_path=Path(f"/tmp/{name}"),
+ format="text",
+ char_count=11,
+ )
+
+
+def _make_result(
+ output_path: Path | None = None,
+ warnings: list[str] | None = None,
+) -> KBGenerationResult:
+ """Create a sample KBGenerationResult for testing."""
+ return KBGenerationResult(
+ kb_text="# Career KB\n\n## Summary\nGenerated.",
+ source_documents=[_make_doc()],
+ output_path=output_path,
+ validation_warnings=warnings or [],
+ )
+
+
+def _run_mkcv(*args: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
+ """Run mkcv CLI via subprocess and return the result."""
+ return subprocess.run(
+ ["uv", "run", "mkcv", *args],
+ capture_output=True,
+ text=True,
+ cwd=cwd,
+ timeout=30,
+ )
+
+
+# ------------------------------------------------------------------
+# CLI help tests (subprocess)
+# ------------------------------------------------------------------
+
+
+class TestKBHelp:
+ """Tests for mkcv kb --help output."""
+
+ def test_kb_help_exits_zero(self) -> None:
+ result = _run_mkcv("kb", "--help")
+ assert result.returncode == 0
+
+ def test_kb_help_mentions_generate(self) -> None:
+ result = _run_mkcv("kb", "--help")
+ assert "generate" in result.stdout.lower()
+
+ def test_kb_help_mentions_update(self) -> None:
+ result = _run_mkcv("kb", "--help")
+ assert "update" in result.stdout.lower()
+
+
+class TestKBGenerateHelp:
+ """Tests for mkcv kb generate --help."""
+
+ def test_generate_help_exits_zero(self) -> None:
+ result = _run_mkcv("kb", "generate", "--help")
+ assert result.returncode == 0
+
+ def test_generate_help_mentions_sources(self) -> None:
+ result = _run_mkcv("kb", "generate", "--help")
+ assert "source" in result.stdout.lower()
+
+ def test_generate_help_mentions_output(self) -> None:
+ result = _run_mkcv("kb", "generate", "--help")
+ assert "output" in result.stdout.lower()
+
+ def test_generate_help_mentions_name(self) -> None:
+ result = _run_mkcv("kb", "generate", "--help")
+ assert "name" in result.stdout.lower()
+
+ def test_generate_help_mentions_glob(self) -> None:
+ result = _run_mkcv("kb", "generate", "--help")
+ assert "glob" in result.stdout.lower()
+
+
+class TestKBUpdateHelp:
+ """Tests for mkcv kb update --help."""
+
+ def test_update_help_exits_zero(self) -> None:
+ result = _run_mkcv("kb", "update", "--help")
+ assert result.returncode == 0
+
+ def test_update_help_mentions_sources(self) -> None:
+ result = _run_mkcv("kb", "update", "--help")
+ assert "source" in result.stdout.lower()
+
+ def test_update_help_mentions_kb(self) -> None:
+ result = _run_mkcv("kb", "update", "--help")
+ assert "kb" in result.stdout.lower()
+
+
+# ------------------------------------------------------------------
+# kb generate command (mocked service)
+# ------------------------------------------------------------------
+
+
+class TestKBGenerateCommand:
+ """Tests for kb_generate_command with mocked service."""
+
+ def test_generate_calls_service(self, tmp_path: Path) -> None:
+ source = tmp_path / "resume.txt"
+ source.write_text("resume content", encoding="utf-8")
+ output = tmp_path / "kb.md"
+ result = _make_result(output_path=output)
+ mock_service = MagicMock()
+
+ with (
+ patch(f"{_CMD}.settings") as mock_settings,
+ patch(
+ f"{_CMD}.create_kb_generation_service",
+ return_value=mock_service,
+ ),
+ patch(f"{_CMD}.asyncio.run", return_value=result),
+ ):
+ mock_settings.in_workspace = False
+ mock_settings.workspace_root = None
+ from mkcv.cli.commands.kb import kb_generate_command
+
+ kb_generate_command(sources=[source], output=output)
+
+ def test_generate_handles_service_error(self, tmp_path: Path) -> None:
+ source = tmp_path / "resume.txt"
+ source.write_text("content", encoding="utf-8")
+ output = tmp_path / "kb.md"
+
+ with (
+ patch(f"{_CMD}.settings") as mock_settings,
+ patch(
+ f"{_CMD}.create_kb_generation_service",
+ side_effect=KBGenerationError("boom"),
+ ),
+ pytest.raises(SystemExit) as exc_info,
+ ):
+ mock_settings.in_workspace = False
+ mock_settings.workspace_root = None
+ from mkcv.cli.commands.kb import kb_generate_command
+
+ kb_generate_command(sources=[source], output=output)
+
+ assert exc_info.value.code == 9
+
+ def test_generate_handles_runtime_error(self, tmp_path: Path) -> None:
+ source = tmp_path / "resume.txt"
+ source.write_text("content", encoding="utf-8")
+ output = tmp_path / "kb.md"
+ mock_service = MagicMock()
+
+ with (
+ patch(f"{_CMD}.settings") as mock_settings,
+ patch(
+ f"{_CMD}.create_kb_generation_service",
+ return_value=mock_service,
+ ),
+ patch(
+ f"{_CMD}.asyncio.run",
+ side_effect=KBGenerationError("generation failed"),
+ ),
+ pytest.raises(SystemExit) as exc_info,
+ ):
+ mock_settings.in_workspace = False
+ mock_settings.workspace_root = None
+ from mkcv.cli.commands.kb import kb_generate_command
+
+ kb_generate_command(sources=[source], output=output)
+
+ assert exc_info.value.code == 9
+
+
+# ------------------------------------------------------------------
+# kb update command (mocked service)
+# ------------------------------------------------------------------
+
+
+class TestKBUpdateCommand:
+ """Tests for kb_update_command with mocked service."""
+
+ def test_update_calls_service(self, tmp_path: Path) -> None:
+ kb_file = tmp_path / "existing.md"
+ kb_file.write_text("# Old KB", encoding="utf-8")
+ source = tmp_path / "new.txt"
+ source.write_text("new content", encoding="utf-8")
+ result = _make_result(output_path=kb_file)
+ mock_service = MagicMock()
+
+ with (
+ patch(f"{_CMD}.settings") as mock_settings,
+ patch(
+ f"{_CMD}.create_kb_generation_service",
+ return_value=mock_service,
+ ),
+ patch(f"{_CMD}.asyncio.run", return_value=result),
+ ):
+ mock_settings.in_workspace = False
+ mock_settings.workspace_root = None
+ from mkcv.cli.commands.kb import kb_update_command
+
+ kb_update_command(sources=[source], kb=kb_file)
+
+ def test_update_nonexistent_kb_exits(self, tmp_path: Path) -> None:
+ source = tmp_path / "new.txt"
+ source.write_text("content", encoding="utf-8")
+ ghost_kb = tmp_path / "nonexistent.md"
+
+ with (
+ patch(f"{_CMD}.settings") as mock_settings,
+ pytest.raises(SystemExit) as exc_info,
+ ):
+ mock_settings.in_workspace = False
+ mock_settings.workspace_root = None
+ from mkcv.cli.commands.kb import kb_update_command
+
+ kb_update_command(sources=[source], kb=ghost_kb)
+
+ assert exc_info.value.code == 2
+
+ def test_update_no_kb_specified_no_workspace_exits(self, tmp_path: Path) -> None:
+ source = tmp_path / "new.txt"
+ source.write_text("content", encoding="utf-8")
+
+ with (
+ patch(f"{_CMD}.settings") as mock_settings,
+ pytest.raises(SystemExit) as exc_info,
+ ):
+ mock_settings.in_workspace = False
+ mock_settings.workspace_root = None
+ from mkcv.cli.commands.kb import kb_update_command
+
+ kb_update_command(sources=[source])
+
+ assert exc_info.value.code == 2
+
+
+# ------------------------------------------------------------------
+# Path resolution helpers
+# ------------------------------------------------------------------
+
+
+class TestResolveOutput:
+ """Tests for _resolve_output helper."""
+
+ def test_explicit_output_returned(self) -> None:
+ from mkcv.cli.commands.kb import _resolve_output
+
+ p = Path("/tmp/my-kb.md")
+ assert _resolve_output(p) == p
+
+ def test_default_output_when_no_workspace(self) -> None:
+ from mkcv.cli.commands.kb import _resolve_output
+
+ with patch(f"{_CMD}.settings") as mock_settings:
+ mock_settings.in_workspace = False
+ mock_settings.workspace_root = None
+ result = _resolve_output(None)
+ assert result.name == "career-kb.md"
+
+
+class TestResolveExistingKB:
+ """Tests for _resolve_existing_kb helper."""
+
+ def test_explicit_kb_returned(self) -> None:
+ from mkcv.cli.commands.kb import _resolve_existing_kb
+
+ p = Path("/tmp/my-kb.md")
+ assert _resolve_existing_kb(p) == p
+
+ def test_returns_none_when_no_workspace(self) -> None:
+ from mkcv.cli.commands.kb import _resolve_existing_kb
+
+ with patch(f"{_CMD}.settings") as mock_settings:
+ mock_settings.in_workspace = False
+ mock_settings.workspace_root = None
+ assert _resolve_existing_kb(None) is None
diff --git a/tests/test_core/test_kb_generation_exceptions.py b/tests/test_core/test_kb_generation_exceptions.py
new file mode 100644
index 0000000..037e1dc
--- /dev/null
+++ b/tests/test_core/test_kb_generation_exceptions.py
@@ -0,0 +1,40 @@
+"""Tests for KB generation exception classes."""
+
+from mkcv.core.exceptions.base import MkcvError
+from mkcv.core.exceptions.kb_generation import DocumentReadError, KBGenerationError
+
+
+class TestKBGenerationError:
+ """Tests for KBGenerationError."""
+
+ def test_exit_code_is_nine(self) -> None:
+ err = KBGenerationError("generation failed")
+ assert err.exit_code == 9
+
+ def test_message_is_preserved(self) -> None:
+ err = KBGenerationError("something broke")
+ assert str(err) == "something broke"
+
+ def test_inherits_from_mkcv_error(self) -> None:
+ assert issubclass(KBGenerationError, MkcvError)
+
+ def test_is_exception(self) -> None:
+ assert issubclass(KBGenerationError, Exception)
+
+
+class TestDocumentReadError:
+ """Tests for DocumentReadError."""
+
+ def test_exit_code_is_nine(self) -> None:
+ err = DocumentReadError("cannot read file")
+ assert err.exit_code == 9
+
+ def test_message_is_preserved(self) -> None:
+ err = DocumentReadError("bad file format")
+ assert str(err) == "bad file format"
+
+ def test_inherits_from_mkcv_error(self) -> None:
+ assert issubclass(DocumentReadError, MkcvError)
+
+ def test_is_exception(self) -> None:
+ assert issubclass(DocumentReadError, Exception)
diff --git a/tests/test_core/test_models/test_document_content.py b/tests/test_core/test_models/test_document_content.py
new file mode 100644
index 0000000..21fd9c1
--- /dev/null
+++ b/tests/test_core/test_models/test_document_content.py
@@ -0,0 +1,115 @@
+"""Tests for DocumentContent model."""
+
+from pathlib import Path
+
+import pytest
+from pydantic import ValidationError
+
+from mkcv.core.models.document_content import DocumentContent
+
+
+class TestDocumentContentValid:
+ """Tests for valid DocumentContent creation."""
+
+ def test_minimal_valid_instance(self) -> None:
+ doc = DocumentContent(
+ text="Hello world",
+ source_path=Path("/tmp/test.txt"),
+ format="text",
+ char_count=11,
+ )
+ assert doc.text == "Hello world"
+ assert doc.source_path == Path("/tmp/test.txt")
+ assert doc.format == "text"
+ assert doc.char_count == 11
+
+ def test_metadata_defaults_to_empty_dict(self) -> None:
+ doc = DocumentContent(
+ text="content",
+ source_path=Path("/tmp/file.md"),
+ format="markdown",
+ char_count=7,
+ )
+ assert doc.metadata == {}
+
+ def test_metadata_is_preserved(self) -> None:
+ meta = {"title": "My Resume", "author": "Jane Doe"}
+ doc = DocumentContent(
+ text="content",
+ source_path=Path("/tmp/file.pdf"),
+ format="pdf",
+ char_count=7,
+ metadata=meta,
+ )
+ assert doc.metadata == meta
+
+ def test_char_count_zero_is_valid(self) -> None:
+ doc = DocumentContent(
+ text="",
+ source_path=Path("/tmp/empty.txt"),
+ format="text",
+ char_count=0,
+ )
+ assert doc.char_count == 0
+
+ def test_model_dump_includes_all_fields(self) -> None:
+ doc = DocumentContent(
+ text="test",
+ source_path=Path("/tmp/test.txt"),
+ format="text",
+ char_count=4,
+ metadata={"key": "value"},
+ )
+ data = doc.model_dump()
+ assert set(data.keys()) == {
+ "text",
+ "source_path",
+ "format",
+ "char_count",
+ "metadata",
+ }
+
+
+class TestDocumentContentInvalid:
+ """Tests for invalid DocumentContent creation."""
+
+ def test_negative_char_count_raises(self) -> None:
+ with pytest.raises(ValidationError):
+ DocumentContent(
+ text="hello",
+ source_path=Path("/tmp/test.txt"),
+ format="text",
+ char_count=-1,
+ )
+
+ def test_missing_text_raises(self) -> None:
+ with pytest.raises(ValidationError):
+ DocumentContent( # type: ignore[call-arg]
+ source_path=Path("/tmp/test.txt"),
+ format="text",
+ char_count=0,
+ )
+
+ def test_missing_source_path_raises(self) -> None:
+ with pytest.raises(ValidationError):
+ DocumentContent( # type: ignore[call-arg]
+ text="hello",
+ format="text",
+ char_count=5,
+ )
+
+ def test_missing_format_raises(self) -> None:
+ with pytest.raises(ValidationError):
+ DocumentContent( # type: ignore[call-arg]
+ text="hello",
+ source_path=Path("/tmp/test.txt"),
+ char_count=5,
+ )
+
+ def test_missing_char_count_raises(self) -> None:
+ with pytest.raises(ValidationError):
+ DocumentContent( # type: ignore[call-arg]
+ text="hello",
+ source_path=Path("/tmp/test.txt"),
+ format="text",
+ )
diff --git a/tests/test_core/test_models/test_kb_generation_result.py b/tests/test_core/test_models/test_kb_generation_result.py
new file mode 100644
index 0000000..dfc447d
--- /dev/null
+++ b/tests/test_core/test_models/test_kb_generation_result.py
@@ -0,0 +1,101 @@
+"""Tests for KBGenerationResult model."""
+
+from pathlib import Path
+
+import pytest
+from pydantic import ValidationError
+
+from mkcv.core.models.document_content import DocumentContent
+from mkcv.core.models.kb_generation_result import KBGenerationResult
+
+
+def _make_doc(name: str = "test.txt", text: str = "content") -> DocumentContent:
+ """Create a sample DocumentContent for testing."""
+ return DocumentContent(
+ text=text,
+ source_path=Path(f"/tmp/{name}"),
+ format="text",
+ char_count=len(text),
+ )
+
+
+class TestKBGenerationResultValid:
+ """Tests for valid KBGenerationResult creation."""
+
+ def test_minimal_valid_instance(self) -> None:
+ result = KBGenerationResult(
+ kb_text="# Career KB\n\n## Summary\nTest.",
+ source_documents=[_make_doc()],
+ )
+ assert result.kb_text.startswith("# Career KB")
+ assert len(result.source_documents) == 1
+
+ def test_output_path_defaults_to_none(self) -> None:
+ result = KBGenerationResult(
+ kb_text="# KB",
+ source_documents=[_make_doc()],
+ )
+ assert result.output_path is None
+
+ def test_validation_warnings_defaults_to_empty(self) -> None:
+ result = KBGenerationResult(
+ kb_text="# KB",
+ source_documents=[_make_doc()],
+ )
+ assert result.validation_warnings == []
+
+ def test_output_path_is_preserved(self) -> None:
+ result = KBGenerationResult(
+ kb_text="# KB",
+ source_documents=[_make_doc()],
+ output_path=Path("/tmp/output.md"),
+ )
+ assert result.output_path == Path("/tmp/output.md")
+
+ def test_validation_warnings_are_preserved(self) -> None:
+ warnings = ["Missing section: Education", "KB is short"]
+ result = KBGenerationResult(
+ kb_text="# KB",
+ source_documents=[_make_doc()],
+ validation_warnings=warnings,
+ )
+ assert result.validation_warnings == warnings
+
+ def test_multiple_source_documents(self) -> None:
+ docs = [_make_doc("a.txt", "aaa"), _make_doc("b.pdf", "bbb")]
+ result = KBGenerationResult(
+ kb_text="# KB",
+ source_documents=docs,
+ )
+ assert len(result.source_documents) == 2
+
+ def test_model_dump_includes_all_fields(self) -> None:
+ result = KBGenerationResult(
+ kb_text="# KB",
+ source_documents=[_make_doc()],
+ output_path=Path("/tmp/out.md"),
+ validation_warnings=["warn"],
+ )
+ data = result.model_dump()
+ assert set(data.keys()) == {
+ "kb_text",
+ "source_documents",
+ "output_path",
+ "validation_warnings",
+ }
+
+
+class TestKBGenerationResultInvalid:
+ """Tests for invalid KBGenerationResult creation."""
+
+ def test_missing_kb_text_raises(self) -> None:
+ with pytest.raises(ValidationError):
+ KBGenerationResult( # type: ignore[call-arg]
+ source_documents=[_make_doc()],
+ )
+
+ def test_missing_source_documents_raises(self) -> None:
+ with pytest.raises(ValidationError):
+ KBGenerationResult( # type: ignore[call-arg]
+ kb_text="# KB",
+ )
diff --git a/tests/test_core/test_services/test_kb_generation_service.py b/tests/test_core/test_services/test_kb_generation_service.py
new file mode 100644
index 0000000..58d1a28
--- /dev/null
+++ b/tests/test_core/test_services/test_kb_generation_service.py
@@ -0,0 +1,436 @@
+"""Tests for KBGenerationService."""
+
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from mkcv.core.exceptions.kb_generation import KBGenerationError
+from mkcv.core.exceptions.provider import ProviderError
+from mkcv.core.exceptions.rate_limit import RateLimitError
+from mkcv.core.models.document_content import DocumentContent
+from mkcv.core.models.kb_generation_result import KBGenerationResult
+from mkcv.core.services.kb_generation_service import (
+ KBGenerationService,
+ _strip_code_fences,
+)
+
+# ------------------------------------------------------------------
+# Helpers
+# ------------------------------------------------------------------
+
+
+def _make_doc(
+ name: str = "test.txt",
+ text: str = "Sample content for testing.",
+ fmt: str = "text",
+) -> DocumentContent:
+ """Create a sample DocumentContent."""
+ return DocumentContent(
+ text=text,
+ source_path=Path(f"/tmp/{name}"),
+ format=fmt,
+ char_count=len(text),
+ )
+
+
+_DEFAULT_LLM_RESPONSE = (
+ "# Career KB\n\n"
+ "## Personal Information\n\n"
+ "| Field | Value |\n|---|---|\n| Name | Jane |\n\n"
+ "## Professional Summary\n\nSenior engineer.\n\n"
+ "## Technical Skills\n\n- Python\n\n"
+ "## Career History\n\n"
+ "### Acme -- Engineer\n**2020-2024**\n\n- Built systems\n\n"
+ "## Education\n\n### MIT -- BS CS\n**2016**"
+)
+
+
+def _make_service(
+ documents: list[DocumentContent] | None = None,
+ llm_response: str = _DEFAULT_LLM_RESPONSE,
+ chunk_threshold: int = 100_000,
+) -> tuple[KBGenerationService, MagicMock, AsyncMock, MagicMock]:
+ """Create a KBGenerationService with mocked dependencies.
+
+ Returns (service, mock_reader, mock_llm, mock_prompts).
+ """
+ mock_reader = MagicMock()
+ mock_reader.supported_extensions.return_value = {
+ ".txt",
+ ".md",
+ ".pdf",
+ ".docx",
+ ".html",
+ }
+ mock_reader.read_file.side_effect = lambda p: _make_doc(p.name, f"text of {p.name}")
+
+ mock_llm = AsyncMock()
+ mock_llm.complete.return_value = llm_response
+
+ mock_prompts = MagicMock()
+ mock_prompts.render.return_value = "rendered prompt text"
+
+ service = KBGenerationService(
+ document_reader=mock_reader,
+ llm=mock_llm,
+ prompts=mock_prompts,
+ chunk_threshold=chunk_threshold,
+ )
+
+ return service, mock_reader, mock_llm, mock_prompts
+
+
+# ------------------------------------------------------------------
+# generate() happy path
+# ------------------------------------------------------------------
+
+
+class TestGenerateHappyPath:
+ """Tests for KBGenerationService.generate() success scenarios."""
+
+ @pytest.mark.asyncio
+ async def test_generate_returns_kb_generation_result(self, tmp_path: Path) -> None:
+ service, _reader, _, _ = _make_service()
+ source_file = tmp_path / "resume.txt"
+ source_file.write_text("resume content", encoding="utf-8")
+
+ result = await service.generate(sources=[source_file])
+ assert isinstance(result, KBGenerationResult)
+
+ @pytest.mark.asyncio
+ async def test_generate_returns_kb_text(self, tmp_path: Path) -> None:
+ service, _, _, _ = _make_service()
+ source_file = tmp_path / "resume.txt"
+ source_file.write_text("resume content", encoding="utf-8")
+
+ result = await service.generate(sources=[source_file])
+ assert "Career KB" in result.kb_text
+
+ @pytest.mark.asyncio
+ async def test_generate_writes_output_file(self, tmp_path: Path) -> None:
+ service, _, _, _ = _make_service()
+ source_file = tmp_path / "resume.txt"
+ source_file.write_text("resume content", encoding="utf-8")
+ output = tmp_path / "kb.md"
+
+ result = await service.generate(sources=[source_file], output=output)
+ assert result.output_path is not None
+ assert output.exists()
+ assert output.read_text(encoding="utf-8") == result.kb_text
+
+ @pytest.mark.asyncio
+ async def test_generate_no_output_path_when_not_specified(
+ self, tmp_path: Path
+ ) -> None:
+ service, _, _, _ = _make_service()
+ source_file = tmp_path / "resume.txt"
+ source_file.write_text("resume content", encoding="utf-8")
+
+ result = await service.generate(sources=[source_file])
+ assert result.output_path is None
+
+ @pytest.mark.asyncio
+ async def test_generate_passes_kb_name_to_prompt(self, tmp_path: Path) -> None:
+ service, _, _, mock_prompts = _make_service()
+ source_file = tmp_path / "resume.txt"
+ source_file.write_text("resume content", encoding="utf-8")
+
+ await service.generate(sources=[source_file], kb_name="Engineering")
+ call_args = mock_prompts.render.call_args
+ assert call_args[0][0] == "kb_generate.j2"
+ context = call_args[0][1]
+ assert context["kb_name"] == "Engineering"
+
+ @pytest.mark.asyncio
+ async def test_generate_calls_llm(self, tmp_path: Path) -> None:
+ service, _, mock_llm, _ = _make_service()
+ source_file = tmp_path / "resume.txt"
+ source_file.write_text("resume content", encoding="utf-8")
+
+ await service.generate(sources=[source_file])
+ mock_llm.complete.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_generate_returns_validation_warnings(self, tmp_path: Path) -> None:
+ # Use a KB response that will trigger validation warnings
+ # (short, missing sections)
+ short_kb = "# KB\n\n## Summary\n\nShort."
+ service, _, _, _ = _make_service(llm_response=short_kb)
+ source_file = tmp_path / "resume.txt"
+ source_file.write_text("resume content", encoding="utf-8")
+
+ result = await service.generate(sources=[source_file])
+ assert isinstance(result.validation_warnings, list)
+
+
+# ------------------------------------------------------------------
+# generate() error handling
+# ------------------------------------------------------------------
+
+
+class TestGenerateErrors:
+ """Tests for KBGenerationService.generate() error scenarios."""
+
+ @pytest.mark.asyncio
+ async def test_no_documents_found_raises(self, tmp_path: Path) -> None:
+ service, _, _, _ = _make_service()
+ empty_dir = tmp_path / "empty"
+ empty_dir.mkdir()
+
+ with pytest.raises(KBGenerationError, match="No supported documents"):
+ await service.generate(sources=[empty_dir])
+
+ @pytest.mark.asyncio
+ async def test_llm_failure_raises_kb_generation_error(self, tmp_path: Path) -> None:
+ service, _, mock_llm, _ = _make_service()
+ mock_llm.complete.side_effect = RuntimeError("LLM exploded")
+ source_file = tmp_path / "resume.txt"
+ source_file.write_text("content", encoding="utf-8")
+
+ with pytest.raises(KBGenerationError, match="LLM call failed"):
+ await service.generate(sources=[source_file])
+
+ @pytest.mark.asyncio
+ async def test_rate_limit_error_propagated(self, tmp_path: Path) -> None:
+ service, _, mock_llm, _ = _make_service()
+ mock_llm.complete.side_effect = RateLimitError(
+ "rate limited", provider="anthropic"
+ )
+ source_file = tmp_path / "resume.txt"
+ source_file.write_text("content", encoding="utf-8")
+
+ with pytest.raises(RateLimitError):
+ await service.generate(sources=[source_file])
+
+ @pytest.mark.asyncio
+ async def test_provider_error_propagated(self, tmp_path: Path) -> None:
+ service, _, mock_llm, _ = _make_service()
+ mock_llm.complete.side_effect = ProviderError(
+ "provider down", provider="anthropic"
+ )
+ source_file = tmp_path / "resume.txt"
+ source_file.write_text("content", encoding="utf-8")
+
+ with pytest.raises(ProviderError):
+ await service.generate(sources=[source_file])
+
+
+# ------------------------------------------------------------------
+# generate() chunked processing
+# ------------------------------------------------------------------
+
+
+class TestGenerateChunked:
+ """Tests for chunked processing of large inputs."""
+
+ @pytest.mark.asyncio
+ async def test_chunked_when_over_threshold(self, tmp_path: Path) -> None:
+ """When total chars exceed threshold, multiple LLM calls are made."""
+ service, _, mock_llm, _prompts = _make_service(
+ chunk_threshold=50,
+ )
+ source_file = tmp_path / "big.txt"
+ source_file.write_text("x" * 100, encoding="utf-8")
+
+ # We need to mock _read_documents to return large docs
+ big_doc = _make_doc("big.txt", "x" * 100)
+ service._read_documents = MagicMock(return_value=[big_doc]) # type: ignore[method-assign]
+
+ await service.generate(sources=[source_file])
+ # Single chunk (one doc exceeding threshold -> its own chunk)
+ # -> 1 call for generate
+ assert mock_llm.complete.call_count >= 1
+
+ @pytest.mark.asyncio
+ async def test_chunked_multiple_docs(self, tmp_path: Path) -> None:
+ """Multiple docs over threshold trigger chunk + merge."""
+ service, _, mock_llm, _ = _make_service(
+ chunk_threshold=50,
+ )
+ source_file = tmp_path / "a.txt"
+ source_file.write_text("aaa", encoding="utf-8")
+
+ docs = [
+ _make_doc("a.txt", "a" * 30),
+ _make_doc("b.txt", "b" * 30),
+ ]
+ service._read_documents = MagicMock(return_value=docs) # type: ignore[method-assign]
+
+ await service.generate(sources=[source_file])
+ # Two chunks -> 2 generate calls + 1 merge call = 3
+ assert mock_llm.complete.call_count == 3
+
+
+# ------------------------------------------------------------------
+# update() happy path
+# ------------------------------------------------------------------
+
+
+class TestUpdateHappyPath:
+ """Tests for KBGenerationService.update() success scenarios."""
+
+ @pytest.mark.asyncio
+ async def test_update_returns_kb_generation_result(self, tmp_path: Path) -> None:
+ service, _, _, _ = _make_service()
+ kb_file = tmp_path / "existing.md"
+ kb_file.write_text("# Existing KB\n\n## Summary\n\nOld data.", encoding="utf-8")
+ source_file = tmp_path / "new.txt"
+ source_file.write_text("new content", encoding="utf-8")
+
+ result = await service.update(
+ existing_kb_path=kb_file,
+ sources=[source_file],
+ )
+ assert isinstance(result, KBGenerationResult)
+
+ @pytest.mark.asyncio
+ async def test_update_writes_back_to_existing_path(self, tmp_path: Path) -> None:
+ service, _, _, _ = _make_service()
+ kb_file = tmp_path / "existing.md"
+ kb_file.write_text("# Old KB", encoding="utf-8")
+ source_file = tmp_path / "new.txt"
+ source_file.write_text("new content", encoding="utf-8")
+
+ result = await service.update(
+ existing_kb_path=kb_file,
+ sources=[source_file],
+ )
+ assert result.output_path is not None
+ assert kb_file.read_text(encoding="utf-8") == result.kb_text
+
+ @pytest.mark.asyncio
+ async def test_update_uses_kb_update_template(self, tmp_path: Path) -> None:
+ service, _, _, mock_prompts = _make_service()
+ kb_file = tmp_path / "existing.md"
+ kb_file.write_text("# Old KB", encoding="utf-8")
+ source_file = tmp_path / "new.txt"
+ source_file.write_text("new content", encoding="utf-8")
+
+ await service.update(
+ existing_kb_path=kb_file,
+ sources=[source_file],
+ )
+ call_args = mock_prompts.render.call_args
+ assert call_args[0][0] == "kb_update.j2"
+ context = call_args[0][1]
+ assert "existing_kb" in context
+ assert "document_texts" in context
+
+
+# ------------------------------------------------------------------
+# update() error handling
+# ------------------------------------------------------------------
+
+
+class TestUpdateErrors:
+ """Tests for KBGenerationService.update() error scenarios."""
+
+ @pytest.mark.asyncio
+ async def test_nonexistent_kb_raises(self, tmp_path: Path) -> None:
+ service, _, _, _ = _make_service()
+ ghost = tmp_path / "nonexistent.md"
+ source_file = tmp_path / "new.txt"
+ source_file.write_text("content", encoding="utf-8")
+
+ with pytest.raises(KBGenerationError, match="not found"):
+ await service.update(
+ existing_kb_path=ghost,
+ sources=[source_file],
+ )
+
+ @pytest.mark.asyncio
+ async def test_no_new_documents_raises(self, tmp_path: Path) -> None:
+ service, _, _, _ = _make_service()
+ kb_file = tmp_path / "existing.md"
+ kb_file.write_text("# KB", encoding="utf-8")
+ empty_dir = tmp_path / "empty"
+ empty_dir.mkdir()
+
+ with pytest.raises(KBGenerationError, match="No supported documents"):
+ await service.update(
+ existing_kb_path=kb_file,
+ sources=[empty_dir],
+ )
+
+
+# ------------------------------------------------------------------
+# _strip_code_fences helper
+# ------------------------------------------------------------------
+
+
+class TestStripCodeFences:
+ """Tests for the _strip_code_fences utility function."""
+
+ def test_strips_markdown_fences(self) -> None:
+ text = "```markdown\n# KB\n\nContent\n```"
+ assert _strip_code_fences(text) == "# KB\n\nContent"
+
+ def test_strips_plain_fences(self) -> None:
+ text = "```\n# KB\n\nContent\n```"
+ assert _strip_code_fences(text) == "# KB\n\nContent"
+
+ def test_no_fences_unchanged(self) -> None:
+ text = "# KB\n\nContent"
+ assert _strip_code_fences(text) == text
+
+ def test_only_opening_fence_partial_strip(self) -> None:
+ text = "```markdown\n# KB\n\nContent"
+ result = _strip_code_fences(text)
+ assert "# KB" in result
+
+
+# ------------------------------------------------------------------
+# _split_into_chunks helper
+# ------------------------------------------------------------------
+
+
+class TestSplitIntoChunks:
+ """Tests for document chunking logic."""
+
+ def test_single_small_doc_one_chunk(self) -> None:
+ service, _, _, _ = _make_service(chunk_threshold=1000)
+ docs = [_make_doc("a.txt", "short")]
+ chunks = service._split_into_chunks(docs)
+ assert len(chunks) == 1
+ assert len(chunks[0]) == 1
+
+ def test_multiple_small_docs_one_chunk(self) -> None:
+ service, _, _, _ = _make_service(chunk_threshold=1000)
+ docs = [_make_doc("a.txt", "aaa"), _make_doc("b.txt", "bbb")]
+ chunks = service._split_into_chunks(docs)
+ assert len(chunks) == 1
+ assert len(chunks[0]) == 2
+
+ def test_large_docs_multiple_chunks(self) -> None:
+ service, _, _, _ = _make_service(chunk_threshold=50)
+ docs = [
+ _make_doc("a.txt", "a" * 30),
+ _make_doc("b.txt", "b" * 30),
+ ]
+ chunks = service._split_into_chunks(docs)
+ assert len(chunks) == 2
+
+ def test_single_oversized_doc_own_chunk(self) -> None:
+ service, _, _, _ = _make_service(chunk_threshold=10)
+ docs = [_make_doc("big.txt", "x" * 100)]
+ chunks = service._split_into_chunks(docs)
+ assert len(chunks) == 1
+ assert len(chunks[0]) == 1
+
+
+# ------------------------------------------------------------------
+# _build_document_texts helper
+# ------------------------------------------------------------------
+
+
+class TestBuildDocumentTexts:
+ """Tests for the prompt template data builder."""
+
+ def test_builds_list_of_dicts(self) -> None:
+ docs = [_make_doc("resume.pdf", "my resume"), _make_doc("notes.txt", "notes")]
+ result = KBGenerationService._build_document_texts(docs)
+ assert len(result) == 2
+ assert result[0]["filename"] == "resume.pdf"
+ assert result[0]["text"] == "my resume"
+ assert result[1]["filename"] == "notes.txt"
diff --git a/uv.lock b/uv.lock
index 9572f0e..00ddf89 100644
--- a/uv.lock
+++ b/uv.lock
@@ -61,6 +61,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
]
+[[package]]
+name = "beautifulsoup4"
+version = "4.14.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "soupsieve" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
+]
+
[[package]]
name = "certifi"
version = "2026.2.25"
@@ -257,6 +270,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
+[[package]]
+name = "html2text"
+version = "2025.4.15"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f8/27/e158d86ba1e82967cc2f790b0cb02030d4a8bef58e0c79a8590e9678107f/html2text-2025.4.15.tar.gz", hash = "sha256:948a645f8f0bc3abe7fd587019a2197a12436cd73d0d4908af95bfc8da337588", size = 64316, upload-time = "2025-04-15T04:02:30.045Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/84/1a0f9555fd5f2b1c924ff932d99b40a0f8a6b12f6dd625e2a47f415b00ea/html2text-2025.4.15-py3-none-any.whl", hash = "sha256:00569167ffdab3d7767a4cdf589b7f57e777a5ed28d12907d8c58769ec734acc", size = 34656, upload-time = "2025-04-15T04:02:28.44Z" },
+]
+
[[package]]
name = "httpcore"
version = "1.0.9"
@@ -443,6 +465,86 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" },
]
+[[package]]
+name = "lxml"
+version = "6.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
+ { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
+ { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
+ { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
+ { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
+ { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
+ { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" },
+ { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" },
+ { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" },
+ { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" },
+ { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" },
+ { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
+ { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
+ { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
+ { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
+ { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
+ { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
+ { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
+ { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
+ { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
+ { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
+ { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
+ { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
+ { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
+ { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
+ { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
+ { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
+]
+
[[package]]
name = "markdown"
version = "3.10.2"
@@ -538,18 +640,21 @@ wheels = [
[[package]]
name = "mkcv"
-version = "1.1.2"
+version = "1.2.0"
source = { editable = "." }
dependencies = [
{ name = "anthropic" },
+ { name = "beautifulsoup4" },
{ name = "cyclopts" },
{ name = "dynaconf" },
+ { name = "html2text" },
{ name = "httpx" },
{ name = "jinja2" },
{ name = "openai" },
{ name = "prompt-toolkit" },
{ name = "pydantic" },
{ name = "pypdf" },
+ { name = "python-docx" },
{ name = "rendercv", extra = ["full"] },
{ name = "rich" },
{ name = "tomli-w" },
@@ -563,12 +668,21 @@ dev = [
{ name = "pytest-cov" },
{ name = "ruff" },
]
+kb = [
+ { name = "beautifulsoup4" },
+ { name = "html2text" },
+ { name = "python-docx" },
+]
[package.metadata]
requires-dist = [
{ name = "anthropic", specifier = ">=0.40" },
+ { name = "beautifulsoup4", specifier = ">=4.12" },
+ { name = "beautifulsoup4", marker = "extra == 'kb'", specifier = ">=4.12" },
{ name = "cyclopts", specifier = ">=3.0" },
{ name = "dynaconf", specifier = ">=3.2" },
+ { name = "html2text", specifier = ">=2024.2" },
+ { name = "html2text", marker = "extra == 'kb'", specifier = ">=2024.2" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "jinja2", specifier = ">=3.1" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" },
@@ -579,12 +693,14 @@ requires-dist = [
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" },
+ { name = "python-docx", specifier = ">=1.1" },
+ { name = "python-docx", marker = "extra == 'kb'", specifier = ">=1.1" },
{ name = "rendercv", extras = ["full"], specifier = ">=2.7" },
{ name = "rich", specifier = ">=13.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" },
{ name = "tomli-w", specifier = ">=1.0" },
]
-provides-extras = ["dev"]
+provides-extras = ["kb", "dev"]
[[package]]
name = "mypy"
@@ -860,6 +976,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
+[[package]]
+name = "python-docx"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "lxml" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
+]
+
[[package]]
name = "rendercv"
version = "2.7"
@@ -973,6 +1102,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
+[[package]]
+name = "soupsieve"
+version = "2.8.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
+]
+
[[package]]
name = "tomli-w"
version = "1.2.0"