Remove --before/--at time travel
This commit is contained in:
parent
0813a1c980
commit
0e271eaf4b
21 changed files with 43 additions and 749 deletions
|
|
@ -3,12 +3,16 @@
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Database tags: `haiku-rag tag create/list/delete`, `--at TAG` time travel, tags shown in `history`. Vacuum retains versions back to the oldest tag.
|
- Database tags: `haiku-rag tag create/list/delete`, tags shown in `history`. Vacuum retains versions back to the oldest tag.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- `lancedb` bumped to 0.34.0.
|
- `lancedb` bumped to 0.34.0.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `--before` global flag. There is no read-only replacement; create tags prospectively before important changes and use `tag restore` during a maintenance window.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- `docling-local` text conversion no longer misroutes markdown/HTML content whose first bytes collide with a binary magic signature (e.g. `BM`, `ID3`) to an image or audio backend.
|
- `docling-local` text conversion no longer misroutes markdown/HTML content whose first bytes collide with a binary magic signature (e.g. `BM`, `ID3`) to an image or audio backend.
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ Agentic RAG built on [LanceDB](https://lancedb.com/), [Pydantic AI](https://ai.p
|
||||||
- **MCP server** — Expose as tools for AI assistants (Claude Desktop, etc.)
|
- **MCP server** — Expose as tools for AI assistants (Claude Desktop, etc.)
|
||||||
- **Visual grounding** — View chunks highlighted on original page images
|
- **Visual grounding** — View chunks highlighted on original page images
|
||||||
- **Production ingester** — Long-lived `haiku-ingester` service with persistent SQLite queue, async worker pool with retries and a dead-letter queue, FS / HTTP / S3 / WebDAV source adapters, FastAPI control plane, and a browser dashboard for operators. See [docs/ingester.md](docs/ingester.md).
|
- **Production ingester** — Long-lived `haiku-ingester` service with persistent SQLite queue, async worker pool with retries and a dead-letter queue, FS / HTTP / S3 / WebDAV source adapters, FastAPI control plane, and a browser dashboard for operators. See [docs/ingester.md](docs/ingester.md).
|
||||||
- **Time travel** — Query the database at any historical point with `--before`, or tag states and query them with `--at`
|
- **Tags** — Name database states with `haiku-rag tag` and roll back to them
|
||||||
- **Inspector** — TUI for browsing documents, chunks, and search results
|
- **Inspector** — TUI for browsing documents, chunks, and search results
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
|
||||||
|
|
@ -76,4 +76,4 @@ For everyday Q&A, the rag skill alone is faster and cheaper. Attaching both lets
|
||||||
|
|
||||||
Run "Filter documents" from the command palette to restrict searches to a subset. The filter applies to every search the agent runs for the rest of the session.
|
Run "Filter documents" from the command palette to restrict searches to a subset. The filter applies to every search the agent runs for the rest of the session.
|
||||||
|
|
||||||
Chat also honors the global `--read-only` and `--before` flags. See the [CLI reference](cli.md) for details.
|
Chat also honors the global `--read-only` flag. See the [CLI reference](cli.md) for details.
|
||||||
|
|
|
||||||
49
docs/cli.md
49
docs/cli.md
|
|
@ -7,8 +7,6 @@ The `haiku-rag` CLI provides complete document management functionality.
|
||||||
|
|
||||||
- `--config` - Specify custom configuration file
|
- `--config` - Specify custom configuration file
|
||||||
- `--read-only` - Open database in read-only mode (blocks writes, skips upgrades)
|
- `--read-only` - Open database in read-only mode (blocks writes, skips upgrades)
|
||||||
- `--before` - Query database as it existed before a datetime (implies `--read-only`)
|
|
||||||
- `--at` - Query database at a tag (implies `--read-only`, mutually exclusive with `--before`)
|
|
||||||
- `--version` / `-v` - Show version and exit
|
- `--version` / `-v` - Show version and exit
|
||||||
|
|
||||||
Per-command options:
|
Per-command options:
|
||||||
|
|
@ -21,7 +19,6 @@ The `haiku-rag` CLI provides complete document management functionality.
|
||||||
haiku-rag --config /path/to/config.yaml list
|
haiku-rag --config /path/to/config.yaml list
|
||||||
haiku-rag --config /path/to/config.yaml list --db /path/to/custom.db
|
haiku-rag --config /path/to/config.yaml list --db /path/to/custom.db
|
||||||
haiku-rag --read-only search "query"
|
haiku-rag --read-only search "query"
|
||||||
haiku-rag --before "2025-01-15" search "query"
|
|
||||||
haiku-rag add -h
|
haiku-rag add -h
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -543,39 +540,9 @@ haiku-skills chat --use-entrypoints --skill medic
|
||||||
└── haiku.rag.yaml # Optional config
|
└── haiku.rag.yaml # Optional config
|
||||||
```
|
```
|
||||||
|
|
||||||
## Time Travel
|
## Tags
|
||||||
|
|
||||||
LanceDB maintains version history for tables, enabling you to query the database as it existed at a previous point in time. This is useful for:
|
Tags name the current database state so you can return to it. A tag covers every table in the database. Tagged versions survive `vacuum`; everything older than your oldest tag is retained until that tag is deleted, so remove tags you no longer need.
|
||||||
|
|
||||||
- **Debugging**: Investigate data before a problematic change
|
|
||||||
- **Auditing**: Verify what knowledge was available when a support ticket was filed
|
|
||||||
|
|
||||||
### Query Historical State
|
|
||||||
|
|
||||||
Use `--before` to query the database as it existed before a specific datetime:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Query documents as of January 15, 2025
|
|
||||||
haiku-rag --before "2025-01-15" list
|
|
||||||
|
|
||||||
# Search historical state
|
|
||||||
haiku-rag --before "2025-01-15T14:30:00" search "machine learning"
|
|
||||||
|
|
||||||
# Ask questions against historical data
|
|
||||||
haiku-rag --before "2025-01-15" ask "What documents existed?"
|
|
||||||
```
|
|
||||||
|
|
||||||
Supported datetime formats:
|
|
||||||
|
|
||||||
- ISO 8601: `2025-01-15T14:30:00`, `2025-01-15T14:30:00Z`, `2025-01-15T14:30:00+00:00`
|
|
||||||
- Date only: `2025-01-15` (interpreted as start of day)
|
|
||||||
|
|
||||||
!!! note
|
|
||||||
Time travel mode automatically enables read-only mode. You cannot modify the database while viewing historical state.
|
|
||||||
|
|
||||||
### Tags
|
|
||||||
|
|
||||||
Tags name the current database state so you can return to it without remembering timestamps. A tag covers every table in the database. Tagged versions survive `vacuum`; everything older than your oldest tag is retained until that tag is deleted, so remove tags you no longer need.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Tag the current state, e.g. at deploy time or after an ingestion run
|
# Tag the current state, e.g. at deploy time or after an ingestion run
|
||||||
|
|
@ -588,16 +555,6 @@ haiku-rag tag list
|
||||||
haiku-rag tag delete release-1
|
haiku-rag tag delete release-1
|
||||||
```
|
```
|
||||||
|
|
||||||
Query the database at a tag with `--at`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
haiku-rag --at release-1 list
|
|
||||||
haiku-rag --at release-1 search "machine learning"
|
|
||||||
haiku-rag --at release-1 ask "What documents existed?"
|
|
||||||
```
|
|
||||||
|
|
||||||
`--at` implies read-only mode and is mutually exclusive with `--before`.
|
|
||||||
|
|
||||||
### Version History
|
### Version History
|
||||||
|
|
||||||
View version history for database tables:
|
View version history for database tables:
|
||||||
|
|
@ -628,5 +585,3 @@ chunks
|
||||||
v7: 2025-01-14 10:00:00
|
v7: 2025-01-14 10:00:00
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
Use the timestamps from `history` to construct `--before` queries, or tag names with `--at`.
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
|
@ -36,14 +35,10 @@ class HaikuRAGApp: # pragma: no cover
|
||||||
db_path: Path,
|
db_path: Path,
|
||||||
config: AppConfig = Config,
|
config: AppConfig = Config,
|
||||||
read_only: bool = False,
|
read_only: bool = False,
|
||||||
before: datetime | None = None,
|
|
||||||
at_tag: str | None = None,
|
|
||||||
):
|
):
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
self.config = config
|
self.config = config
|
||||||
self.read_only = read_only
|
self.read_only = read_only
|
||||||
self.before = before
|
|
||||||
self.at_tag = at_tag
|
|
||||||
self.console = Console()
|
self.console = Console()
|
||||||
|
|
||||||
from haiku.rag.store.engine import ConnectionMode
|
from haiku.rag.store.engine import ConnectionMode
|
||||||
|
|
@ -71,11 +66,6 @@ class HaikuRAGApp: # pragma: no cover
|
||||||
|
|
||||||
from haiku.rag.store.engine import gather_database_info
|
from haiku.rag.store.engine import gather_database_info
|
||||||
|
|
||||||
if self.before is not None or self.at_tag is not None:
|
|
||||||
self.console.print(
|
|
||||||
"[yellow]Note: --before/--at is not supported by info; showing current state.[/yellow]"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Basic: show path/URI
|
# Basic: show path/URI
|
||||||
self.console.print("[bold]haiku.rag database info[/bold]")
|
self.console.print("[bold]haiku.rag database info[/bold]")
|
||||||
self.console.print(
|
self.console.print(
|
||||||
|
|
@ -292,8 +282,6 @@ class HaikuRAGApp: # pragma: no cover
|
||||||
skip_validation=True,
|
skip_validation=True,
|
||||||
read_only=True,
|
read_only=True,
|
||||||
skip_migration_check=True,
|
skip_migration_check=True,
|
||||||
before=self.before,
|
|
||||||
at_tag=self.at_tag,
|
|
||||||
) as store:
|
) as store:
|
||||||
tables = [
|
tables = [
|
||||||
"documents",
|
"documents",
|
||||||
|
|
@ -421,8 +409,6 @@ class HaikuRAGApp: # pragma: no cover
|
||||||
db_path=self.db_path,
|
db_path=self.db_path,
|
||||||
config=self.config,
|
config=self.config,
|
||||||
read_only=True,
|
read_only=True,
|
||||||
before=self.before,
|
|
||||||
at_tag=self.at_tag,
|
|
||||||
) as self.client:
|
) as self.client:
|
||||||
documents = await self.client.list_documents(filter=filter)
|
documents = await self.client.list_documents(filter=filter)
|
||||||
for doc in documents:
|
for doc in documents:
|
||||||
|
|
@ -435,8 +421,6 @@ class HaikuRAGApp: # pragma: no cover
|
||||||
db_path=self.db_path,
|
db_path=self.db_path,
|
||||||
config=self.config,
|
config=self.config,
|
||||||
read_only=self.read_only,
|
read_only=self.read_only,
|
||||||
before=self.before,
|
|
||||||
at_tag=self.at_tag,
|
|
||||||
) as self.client:
|
) as self.client:
|
||||||
doc = await self.client.create_document(
|
doc = await self.client.create_document(
|
||||||
text, title=title, metadata=metadata
|
text, title=title, metadata=metadata
|
||||||
|
|
@ -453,8 +437,6 @@ class HaikuRAGApp: # pragma: no cover
|
||||||
db_path=self.db_path,
|
db_path=self.db_path,
|
||||||
config=self.config,
|
config=self.config,
|
||||||
read_only=self.read_only,
|
read_only=self.read_only,
|
||||||
before=self.before,
|
|
||||||
at_tag=self.at_tag,
|
|
||||||
) as self.client:
|
) as self.client:
|
||||||
result = await self.client.create_document_from_source(
|
result = await self.client.create_document_from_source(
|
||||||
source, title=title, metadata=metadata
|
source, title=title, metadata=metadata
|
||||||
|
|
@ -476,8 +458,6 @@ class HaikuRAGApp: # pragma: no cover
|
||||||
db_path=self.db_path,
|
db_path=self.db_path,
|
||||||
config=self.config,
|
config=self.config,
|
||||||
read_only=True,
|
read_only=True,
|
||||||
before=self.before,
|
|
||||||
at_tag=self.at_tag,
|
|
||||||
) as self.client:
|
) as self.client:
|
||||||
doc = await self.client.get_document_by_id(doc_id)
|
doc = await self.client.get_document_by_id(doc_id)
|
||||||
if doc is None:
|
if doc is None:
|
||||||
|
|
@ -490,8 +470,6 @@ class HaikuRAGApp: # pragma: no cover
|
||||||
db_path=self.db_path,
|
db_path=self.db_path,
|
||||||
config=self.config,
|
config=self.config,
|
||||||
read_only=self.read_only,
|
read_only=self.read_only,
|
||||||
before=self.before,
|
|
||||||
at_tag=self.at_tag,
|
|
||||||
) as self.client:
|
) as self.client:
|
||||||
deleted = await self.client.delete_document(doc_id)
|
deleted = await self.client.delete_document(doc_id)
|
||||||
if deleted:
|
if deleted:
|
||||||
|
|
@ -535,8 +513,6 @@ class HaikuRAGApp: # pragma: no cover
|
||||||
db_path=self.db_path,
|
db_path=self.db_path,
|
||||||
config=self.config,
|
config=self.config,
|
||||||
read_only=True,
|
read_only=True,
|
||||||
before=self.before,
|
|
||||||
at_tag=self.at_tag,
|
|
||||||
) as self.client:
|
) as self.client:
|
||||||
results = await self.client.search(
|
results = await self.client.search(
|
||||||
search_input,
|
search_input,
|
||||||
|
|
@ -558,8 +534,6 @@ class HaikuRAGApp: # pragma: no cover
|
||||||
db_path=self.db_path,
|
db_path=self.db_path,
|
||||||
config=self.config,
|
config=self.config,
|
||||||
read_only=True,
|
read_only=True,
|
||||||
before=self.before,
|
|
||||||
at_tag=self.at_tag,
|
|
||||||
) as self.client:
|
) as self.client:
|
||||||
chunk = await self.client.get_chunk_by_id(chunk_id)
|
chunk = await self.client.get_chunk_by_id(chunk_id)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
|
|
@ -603,8 +577,6 @@ class HaikuRAGApp: # pragma: no cover
|
||||||
db_path=self.db_path,
|
db_path=self.db_path,
|
||||||
config=self.config,
|
config=self.config,
|
||||||
read_only=True,
|
read_only=True,
|
||||||
before=self.before,
|
|
||||||
at_tag=self.at_tag,
|
|
||||||
) as self.client:
|
) as self.client:
|
||||||
answer, citations = await self.client.ask(question, filter=filter)
|
answer, citations = await self.client.ask(question, filter=filter)
|
||||||
|
|
||||||
|
|
@ -632,8 +604,6 @@ class HaikuRAGApp: # pragma: no cover
|
||||||
db_path=self.db_path,
|
db_path=self.db_path,
|
||||||
config=self.config,
|
config=self.config,
|
||||||
read_only=True,
|
read_only=True,
|
||||||
before=self.before,
|
|
||||||
at_tag=self.at_tag,
|
|
||||||
) as self.client:
|
) as self.client:
|
||||||
self.console.print(f"[bold blue]Question:[/bold blue] {question}")
|
self.console.print(f"[bold blue]Question:[/bold blue] {question}")
|
||||||
self.console.print()
|
self.console.print()
|
||||||
|
|
@ -657,8 +627,6 @@ class HaikuRAGApp: # pragma: no cover
|
||||||
config=self.config,
|
config=self.config,
|
||||||
skip_validation=True,
|
skip_validation=True,
|
||||||
read_only=self.read_only,
|
read_only=self.read_only,
|
||||||
before=self.before,
|
|
||||||
at_tag=self.at_tag,
|
|
||||||
) as client:
|
) as client:
|
||||||
if mode == RebuildMode.SET_EMBEDDER:
|
if mode == RebuildMode.SET_EMBEDDER:
|
||||||
async for _ in client.rebuild_database(mode=mode):
|
async for _ in client.rebuild_database(mode=mode):
|
||||||
|
|
@ -702,8 +670,6 @@ class HaikuRAGApp: # pragma: no cover
|
||||||
config=self.config,
|
config=self.config,
|
||||||
skip_validation=True,
|
skip_validation=True,
|
||||||
read_only=self.read_only,
|
read_only=self.read_only,
|
||||||
before=self.before,
|
|
||||||
at_tag=self.at_tag,
|
|
||||||
) as client:
|
) as client:
|
||||||
await client.vacuum()
|
await client.vacuum()
|
||||||
self.console.print("[bold green]Vacuum completed successfully.[/bold green]")
|
self.console.print("[bold green]Vacuum completed successfully.[/bold green]")
|
||||||
|
|
@ -732,8 +698,6 @@ class HaikuRAGApp: # pragma: no cover
|
||||||
config=self.config,
|
config=self.config,
|
||||||
skip_validation=True,
|
skip_validation=True,
|
||||||
read_only=self.read_only,
|
read_only=self.read_only,
|
||||||
before=self.before,
|
|
||||||
at_tag=self.at_tag,
|
|
||||||
) as client:
|
) as client:
|
||||||
row_count = await client.store.chunks_table.count_rows()
|
row_count = await client.store.chunks_table.count_rows()
|
||||||
self.console.print(f"Chunks in database: {row_count}")
|
self.console.print(f"Chunks in database: {row_count}")
|
||||||
|
|
@ -901,15 +865,9 @@ class HaikuRAGApp: # pragma: no cover
|
||||||
self.db_path,
|
self.db_path,
|
||||||
config=self.config,
|
config=self.config,
|
||||||
read_only=self.read_only,
|
read_only=self.read_only,
|
||||||
before=self.before,
|
|
||||||
at_tag=self.at_tag,
|
|
||||||
):
|
):
|
||||||
server = create_mcp_server(
|
server = create_mcp_server(
|
||||||
self.db_path,
|
self.db_path, config=self.config, read_only=self.read_only
|
||||||
config=self.config,
|
|
||||||
read_only=self.read_only,
|
|
||||||
before=self.before,
|
|
||||||
at_tag=self.at_tag,
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
if transport == "stdio":
|
if transport == "stdio":
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
def run_chat(
|
def run_chat(
|
||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
read_only: bool = False,
|
read_only: bool = False,
|
||||||
before: datetime | None = None,
|
|
||||||
at_tag: str | None = None,
|
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
skills: list[str] | None = None,
|
skills: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -15,8 +12,6 @@ def run_chat(
|
||||||
Args:
|
Args:
|
||||||
db_path: Path to the LanceDB database. If None, uses default from config.
|
db_path: Path to the LanceDB database. If None, uses default from config.
|
||||||
read_only: Whether to open the database in read-only mode.
|
read_only: Whether to open the database in read-only mode.
|
||||||
before: Query database as it existed before this datetime.
|
|
||||||
at_tag: Query database at this tag.
|
|
||||||
model: Model to use for the chat.
|
model: Model to use for the chat.
|
||||||
skills: Skills to enable ("rag", "analysis"). Defaults to ["rag"].
|
skills: Skills to enable ("rag", "analysis"). Defaults to ["rag"].
|
||||||
"""
|
"""
|
||||||
|
|
@ -57,8 +52,6 @@ def run_chat(
|
||||||
db_path,
|
db_path,
|
||||||
skills=skill_list,
|
skills=skill_list,
|
||||||
read_only=read_only,
|
read_only=read_only,
|
||||||
before=before,
|
|
||||||
at_tag=at_tag,
|
|
||||||
model=model or get_model(config.qa.model, config),
|
model=model or get_model(config.qa.model, config),
|
||||||
)
|
)
|
||||||
app.run()
|
app.run()
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import asyncio
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
|
@ -85,16 +84,12 @@ class ChatApp(App):
|
||||||
db_path: Path,
|
db_path: Path,
|
||||||
skills: list[Skill],
|
skills: list[Skill],
|
||||||
read_only: bool = False,
|
read_only: bool = False,
|
||||||
before: datetime | None = None,
|
|
||||||
at_tag: str | None = None,
|
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
self._skills = skills
|
self._skills = skills
|
||||||
self.read_only = read_only
|
self.read_only = read_only
|
||||||
self.before = before
|
|
||||||
self.at_tag = at_tag
|
|
||||||
self._model = model
|
self._model = model
|
||||||
self.client: HaikuRAG | None = None
|
self.client: HaikuRAG | None = None
|
||||||
self.config = get_config()
|
self.config = get_config()
|
||||||
|
|
@ -152,8 +147,6 @@ class ChatApp(App):
|
||||||
db_path=self.db_path,
|
db_path=self.db_path,
|
||||||
config=self.config,
|
config=self.config,
|
||||||
read_only=self.read_only,
|
read_only=self.read_only,
|
||||||
before=self.before,
|
|
||||||
at_tag=self.at_tag,
|
|
||||||
)
|
)
|
||||||
await self.client.__aenter__()
|
await self.client.__aenter__()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import asyncio
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
from datetime import datetime
|
|
||||||
from importlib.metadata import version
|
from importlib.metadata import version
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -47,8 +46,6 @@ def cli():
|
||||||
|
|
||||||
# Module-level flags set by callback
|
# Module-level flags set by callback
|
||||||
_read_only: bool = False
|
_read_only: bool = False
|
||||||
_before: datetime | None = None
|
|
||||||
_at_tag: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def create_app(db: Path | None = None) -> HaikuRAGApp: # pragma: no cover
|
def create_app(db: Path | None = None) -> HaikuRAGApp: # pragma: no cover
|
||||||
|
|
@ -62,13 +59,7 @@ def create_app(db: Path | None = None) -> HaikuRAGApp: # pragma: no cover
|
||||||
"""
|
"""
|
||||||
config = get_config()
|
config = get_config()
|
||||||
db_path = db if db else config.storage.data_dir / "haiku.rag.lancedb"
|
db_path = db if db else config.storage.data_dir / "haiku.rag.lancedb"
|
||||||
return HaikuRAGApp(
|
return HaikuRAGApp(db_path=db_path, config=config, read_only=_read_only)
|
||||||
db_path=db_path,
|
|
||||||
config=config,
|
|
||||||
read_only=_read_only,
|
|
||||||
before=_before,
|
|
||||||
at_tag=_at_tag,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def check_version(): # pragma: no cover
|
async def check_version(): # pragma: no cover
|
||||||
|
|
@ -107,39 +98,10 @@ def main(
|
||||||
"--read-only",
|
"--read-only",
|
||||||
help="Open database in read-only mode",
|
help="Open database in read-only mode",
|
||||||
),
|
),
|
||||||
before: str | None = typer.Option(
|
|
||||||
None,
|
|
||||||
"--before",
|
|
||||||
help="Query database as it existed before this datetime (implies --read-only). "
|
|
||||||
"Accepts ISO 8601 format (e.g., 2025-01-15T14:30:00) or date (e.g., 2025-01-15)",
|
|
||||||
),
|
|
||||||
at: str | None = typer.Option(
|
|
||||||
None,
|
|
||||||
"--at",
|
|
||||||
help="Query database at this tag (implies --read-only). "
|
|
||||||
"Mutually exclusive with --before",
|
|
||||||
),
|
|
||||||
):
|
):
|
||||||
"""haiku.rag CLI - Vector database RAG system"""
|
"""haiku.rag CLI - Vector database RAG system"""
|
||||||
global _read_only, _before, _at_tag
|
global _read_only
|
||||||
_read_only = read_only
|
_read_only = read_only
|
||||||
|
|
||||||
if before is not None and at is not None: # pragma: no cover
|
|
||||||
typer.echo("Error: --before and --at are mutually exclusive")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
# Parse and store before datetime
|
|
||||||
if before is not None: # pragma: no cover
|
|
||||||
from haiku.rag.utils import parse_datetime, to_utc
|
|
||||||
|
|
||||||
try:
|
|
||||||
_before = to_utc(parse_datetime(before))
|
|
||||||
except ValueError as e:
|
|
||||||
typer.echo(f"Error: {e}")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
else:
|
|
||||||
_before = None
|
|
||||||
_at_tag = at
|
|
||||||
# Load config from --config, local folder, or default directory
|
# Load config from --config, local folder, or default directory
|
||||||
config_path = find_config_file(cli_path=config)
|
config_path = find_config_file(cli_path=config)
|
||||||
if config_path:
|
if config_path:
|
||||||
|
|
@ -658,15 +620,6 @@ tag_cli = typer.Typer(
|
||||||
_cli.add_typer(tag_cli, name="tag")
|
_cli.add_typer(tag_cli, name="tag")
|
||||||
|
|
||||||
|
|
||||||
def _reject_time_travel(operation: str) -> None:
|
|
||||||
"""Writable tag operations act on the live database state; combining them
|
|
||||||
with a historical checkout would tag something other than what the user
|
|
||||||
sees."""
|
|
||||||
if _before is not None or _at_tag is not None:
|
|
||||||
typer.echo(f"Error: --before/--at cannot be used with {operation}", err=True)
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
@tag_cli.command("create", help="Tag the current database state")
|
@tag_cli.command("create", help="Tag the current database state")
|
||||||
def tag_create( # pragma: no cover
|
def tag_create( # pragma: no cover
|
||||||
name: str = typer.Argument(help="Name of the tag to create"),
|
name: str = typer.Argument(help="Name of the tag to create"),
|
||||||
|
|
@ -676,7 +629,6 @@ def tag_create( # pragma: no cover
|
||||||
help="Path to the LanceDB database file",
|
help="Path to the LanceDB database file",
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
_reject_time_travel("tag create")
|
|
||||||
app = create_app(db)
|
app = create_app(db)
|
||||||
try:
|
try:
|
||||||
asyncio.run(app.create_tag(name))
|
asyncio.run(app.create_tag(name))
|
||||||
|
|
@ -706,7 +658,6 @@ def tag_delete( # pragma: no cover
|
||||||
help="Path to the LanceDB database file",
|
help="Path to the LanceDB database file",
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
_reject_time_travel("tag delete")
|
|
||||||
app = create_app(db)
|
app = create_app(db)
|
||||||
try:
|
try:
|
||||||
asyncio.run(app.delete_tag(name))
|
asyncio.run(app.delete_tag(name))
|
||||||
|
|
@ -741,7 +692,7 @@ def inspect( # pragma: no cover
|
||||||
raise typer.Exit(1) from e
|
raise typer.Exit(1) from e
|
||||||
|
|
||||||
db_path = db if db else get_config().storage.data_dir / "haiku.rag.lancedb"
|
db_path = db if db else get_config().storage.data_dir / "haiku.rag.lancedb"
|
||||||
run_inspector(db_path, read_only=True, before=_before, at_tag=_at_tag)
|
run_inspector(db_path, read_only=True)
|
||||||
|
|
||||||
|
|
||||||
@_cli.command("chat", help="Launch interactive chat TUI for conversational RAG")
|
@_cli.command("chat", help="Launch interactive chat TUI for conversational RAG")
|
||||||
|
|
@ -772,8 +723,6 @@ def chat( # pragma: no cover
|
||||||
run_chat(
|
run_chat(
|
||||||
db_path,
|
db_path,
|
||||||
read_only=True,
|
read_only=True,
|
||||||
before=_before,
|
|
||||||
at_tag=_at_tag,
|
|
||||||
model=model,
|
model=model,
|
||||||
skills=skills,
|
skills=skills,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import tempfile
|
import tempfile
|
||||||
from collections.abc import AsyncGenerator, Sequence
|
from collections.abc import AsyncGenerator, Sequence
|
||||||
from datetime import datetime
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -72,8 +71,6 @@ class HaikuRAG:
|
||||||
skip_validation: bool = False,
|
skip_validation: bool = False,
|
||||||
create: bool = False,
|
create: bool = False,
|
||||||
read_only: bool = False,
|
read_only: bool = False,
|
||||||
before: datetime | None = None,
|
|
||||||
at_tag: str | None = None,
|
|
||||||
):
|
):
|
||||||
"""Initialize the RAG client with a database path.
|
"""Initialize the RAG client with a database path.
|
||||||
|
|
||||||
|
|
@ -83,10 +80,6 @@ class HaikuRAG:
|
||||||
skip_validation: Whether to skip configuration validation on database load.
|
skip_validation: Whether to skip configuration validation on database load.
|
||||||
create: Whether to create the database if it doesn't exist.
|
create: Whether to create the database if it doesn't exist.
|
||||||
read_only: Whether to open the database in read-only mode.
|
read_only: Whether to open the database in read-only mode.
|
||||||
before: Query the database as it existed at this datetime.
|
|
||||||
Implies read_only=True.
|
|
||||||
at_tag: Query the database at this tag. Implies read_only=True;
|
|
||||||
mutually exclusive with before.
|
|
||||||
"""
|
"""
|
||||||
self._config = config
|
self._config = config
|
||||||
if db_path is None:
|
if db_path is None:
|
||||||
|
|
@ -96,8 +89,6 @@ class HaikuRAG:
|
||||||
self._skip_validation = skip_validation
|
self._skip_validation = skip_validation
|
||||||
self._create = create
|
self._create = create
|
||||||
self._read_only = read_only
|
self._read_only = read_only
|
||||||
self._before = before
|
|
||||||
self._at_tag = at_tag
|
|
||||||
self._vacuum_tasks: set[asyncio.Task] = set()
|
self._vacuum_tasks: set[asyncio.Task] = set()
|
||||||
self._last_vacuum_at: float | None = None
|
self._last_vacuum_at: float | None = None
|
||||||
self._vacuum_dirty = False
|
self._vacuum_dirty = False
|
||||||
|
|
@ -129,8 +120,6 @@ class HaikuRAG:
|
||||||
skip_validation=self._skip_validation,
|
skip_validation=self._skip_validation,
|
||||||
create=self._create,
|
create=self._create,
|
||||||
read_only=self._read_only,
|
read_only=self._read_only,
|
||||||
before=self._before,
|
|
||||||
at_tag=self._at_tag,
|
|
||||||
)
|
)
|
||||||
# If _initialize fails mid-way (e.g. migration check raises after
|
# If _initialize fails mid-way (e.g. migration check raises after
|
||||||
# connect), close the store so we don't leak the LanceDB connection —
|
# connect), close the store so we don't leak the LanceDB connection —
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
|
@ -67,18 +66,10 @@ class InspectorApp(App):
|
||||||
Binding("c", "show_context", "Context", show=True),
|
Binding("c", "show_context", "Context", show=True),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, db_path: Path, read_only: bool = False):
|
||||||
self,
|
|
||||||
db_path: Path,
|
|
||||||
read_only: bool = False,
|
|
||||||
before: datetime | None = None,
|
|
||||||
at_tag: str | None = None,
|
|
||||||
):
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
self.read_only = read_only
|
self.read_only = read_only
|
||||||
self.before = before
|
|
||||||
self.at_tag = at_tag
|
|
||||||
self.client: HaikuRAG | None = None
|
self.client: HaikuRAG | None = None
|
||||||
|
|
||||||
def compose(self) -> "ComposeResult":
|
def compose(self) -> "ComposeResult":
|
||||||
|
|
@ -96,8 +87,6 @@ class InspectorApp(App):
|
||||||
db_path=self.db_path,
|
db_path=self.db_path,
|
||||||
config=config,
|
config=config,
|
||||||
read_only=self.read_only,
|
read_only=self.read_only,
|
||||||
before=self.before,
|
|
||||||
at_tag=self.at_tag,
|
|
||||||
)
|
)
|
||||||
await self.client.__aenter__()
|
await self.client.__aenter__()
|
||||||
|
|
||||||
|
|
@ -240,20 +229,16 @@ class InspectorApp(App):
|
||||||
def run_inspector(
|
def run_inspector(
|
||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
read_only: bool = False,
|
read_only: bool = False,
|
||||||
before: datetime | None = None,
|
|
||||||
at_tag: str | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run the inspector TUI.
|
"""Run the inspector TUI.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db_path: Path to the LanceDB database. If None, uses default from config.
|
db_path: Path to the LanceDB database. If None, uses default from config.
|
||||||
read_only: Whether to open the database in read-only mode.
|
read_only: Whether to open the database in read-only mode.
|
||||||
before: Query database as it existed before this datetime.
|
|
||||||
at_tag: Query database at this tag.
|
|
||||||
"""
|
"""
|
||||||
config = get_config()
|
config = get_config()
|
||||||
if db_path is None:
|
if db_path is None:
|
||||||
db_path = config.storage.data_dir / "haiku.rag.lancedb"
|
db_path = config.storage.data_dir / "haiku.rag.lancedb"
|
||||||
|
|
||||||
app = InspectorApp(db_path, read_only=read_only, before=before, at_tag=at_tag)
|
app = InspectorApp(db_path, read_only=read_only)
|
||||||
app.run()
|
app.run()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -12,11 +11,7 @@ from haiku.rag.utils import format_citations
|
||||||
|
|
||||||
|
|
||||||
def create_mcp_server(
|
def create_mcp_server(
|
||||||
db_path: Path,
|
db_path: Path, config: AppConfig = Config, read_only: bool = False
|
||||||
config: AppConfig = Config,
|
|
||||||
read_only: bool = False,
|
|
||||||
before: datetime | None = None,
|
|
||||||
at_tag: str | None = None,
|
|
||||||
) -> FastMCP:
|
) -> FastMCP:
|
||||||
"""Create an MCP server with the specified database path.
|
"""Create an MCP server with the specified database path.
|
||||||
|
|
||||||
|
|
@ -24,10 +19,7 @@ def create_mcp_server(
|
||||||
db_path: Path to the database file.
|
db_path: Path to the database file.
|
||||||
config: Configuration to use.
|
config: Configuration to use.
|
||||||
read_only: If True, write tools (add_document_*, delete_document) are not registered.
|
read_only: If True, write tools (add_document_*, delete_document) are not registered.
|
||||||
before: Serve the database as it existed at this datetime. Implies read_only.
|
|
||||||
at_tag: Serve the database at this tag. Implies read_only.
|
|
||||||
"""
|
"""
|
||||||
read_only = read_only or before is not None or at_tag is not None
|
|
||||||
mcp = FastMCP("haiku-rag")
|
mcp = FastMCP("haiku-rag")
|
||||||
|
|
||||||
# Write tools - only registered when not in read-only mode
|
# Write tools - only registered when not in read-only mode
|
||||||
|
|
@ -112,8 +104,6 @@ def create_mcp_server(
|
||||||
db_path,
|
db_path,
|
||||||
config=config,
|
config=config,
|
||||||
read_only=read_only,
|
read_only=read_only,
|
||||||
before=before,
|
|
||||||
at_tag=at_tag,
|
|
||||||
) as rag:
|
) as rag:
|
||||||
return await rag.search(
|
return await rag.search(
|
||||||
query, limit=limit, include_images=include_images
|
query, limit=limit, include_images=include_images
|
||||||
|
|
@ -153,8 +143,6 @@ def create_mcp_server(
|
||||||
db_path,
|
db_path,
|
||||||
config=config,
|
config=config,
|
||||||
read_only=read_only,
|
read_only=read_only,
|
||||||
before=before,
|
|
||||||
at_tag=at_tag,
|
|
||||||
) as rag:
|
) as rag:
|
||||||
return await rag.search(
|
return await rag.search(
|
||||||
raw, limit=limit, include_images=include_images
|
raw, limit=limit, include_images=include_images
|
||||||
|
|
@ -170,8 +158,6 @@ def create_mcp_server(
|
||||||
db_path,
|
db_path,
|
||||||
config=config,
|
config=config,
|
||||||
read_only=read_only,
|
read_only=read_only,
|
||||||
before=before,
|
|
||||||
at_tag=at_tag,
|
|
||||||
) as rag:
|
) as rag:
|
||||||
return await rag.get_document_by_id(document_id)
|
return await rag.get_document_by_id(document_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -195,8 +181,6 @@ def create_mcp_server(
|
||||||
db_path,
|
db_path,
|
||||||
config=config,
|
config=config,
|
||||||
read_only=read_only,
|
read_only=read_only,
|
||||||
before=before,
|
|
||||||
at_tag=at_tag,
|
|
||||||
) as rag:
|
) as rag:
|
||||||
documents = await rag.list_documents(limit, offset, filter)
|
documents = await rag.list_documents(limit, offset, filter)
|
||||||
|
|
||||||
|
|
@ -231,8 +215,6 @@ def create_mcp_server(
|
||||||
db_path,
|
db_path,
|
||||||
config=config,
|
config=config,
|
||||||
read_only=read_only,
|
read_only=read_only,
|
||||||
before=before,
|
|
||||||
at_tag=at_tag,
|
|
||||||
) as rag:
|
) as rag:
|
||||||
answer, citations = await rag.ask(question)
|
answer, citations = await rag.ask(question)
|
||||||
if cite and citations:
|
if cite and citations:
|
||||||
|
|
@ -264,8 +246,6 @@ def create_mcp_server(
|
||||||
db_path,
|
db_path,
|
||||||
config=config,
|
config=config,
|
||||||
read_only=read_only,
|
read_only=read_only,
|
||||||
before=before,
|
|
||||||
at_tag=at_tag,
|
|
||||||
) as rag:
|
) as rag:
|
||||||
result = await rag.analyze(question, filter=filter)
|
result = await rag.analyze(question, filter=filter)
|
||||||
return result.answer
|
return result.answer
|
||||||
|
|
|
||||||
|
|
@ -370,18 +370,11 @@ class Store:
|
||||||
skip_validation: bool = False,
|
skip_validation: bool = False,
|
||||||
create: bool = False,
|
create: bool = False,
|
||||||
read_only: bool = False,
|
read_only: bool = False,
|
||||||
before: datetime | None = None,
|
|
||||||
at_tag: str | None = None,
|
|
||||||
skip_migration_check: bool = False,
|
skip_migration_check: bool = False,
|
||||||
):
|
):
|
||||||
if before is not None and at_tag is not None:
|
|
||||||
raise ValueError("before and at_tag are mutually exclusive")
|
|
||||||
self.db_path: Path = db_path
|
self.db_path: Path = db_path
|
||||||
self._config = config
|
self._config = config
|
||||||
self._before = before
|
self._read_only = read_only
|
||||||
self._at_tag = at_tag
|
|
||||||
# Time-travel mode is always read-only
|
|
||||||
self._read_only = read_only or before is not None or at_tag is not None
|
|
||||||
self._create = create
|
self._create = create
|
||||||
self._skip_validation = skip_validation
|
self._skip_validation = skip_validation
|
||||||
self._skip_migration_check = skip_migration_check
|
self._skip_migration_check = skip_migration_check
|
||||||
|
|
@ -439,12 +432,6 @@ class Store:
|
||||||
# pending, before creating any newly-introduced table.
|
# pending, before creating any newly-introduced table.
|
||||||
await self._init_tables(is_new_db)
|
await self._init_tables(is_new_db)
|
||||||
|
|
||||||
# Checkout tables to historical state if before or at_tag is specified
|
|
||||||
if self._before is not None:
|
|
||||||
await self._checkout_tables_before(self._before)
|
|
||||||
if self._at_tag is not None:
|
|
||||||
await self._checkout_tables_at_tag(self._at_tag)
|
|
||||||
|
|
||||||
# Set version for new databases.
|
# Set version for new databases.
|
||||||
if is_new_db and not self._read_only:
|
if is_new_db and not self._read_only:
|
||||||
await self._set_initial_version()
|
await self._set_initial_version()
|
||||||
|
|
@ -958,71 +945,6 @@ class Store:
|
||||||
if not found:
|
if not found:
|
||||||
raise ValueError(f"Tag '{name}' does not exist")
|
raise ValueError(f"Tag '{name}' does not exist")
|
||||||
|
|
||||||
async def _checkout_tables_before(self, before: datetime) -> None:
|
|
||||||
"""Checkout all tables to their state at or before the given datetime.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
before: The datetime to checkout to
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If no version exists before the given datetime
|
|
||||||
"""
|
|
||||||
# LanceDB stores timestamps as naive datetimes in local time.
|
|
||||||
# Convert 'before' to naive local time for comparison.
|
|
||||||
if before.tzinfo is not None:
|
|
||||||
# Convert to local time and make naive
|
|
||||||
before_local = before.astimezone().replace(tzinfo=None)
|
|
||||||
else:
|
|
||||||
# Already naive, assume local time
|
|
||||||
before_local = before
|
|
||||||
|
|
||||||
for table in self._tables().values():
|
|
||||||
versions = await table.list_versions()
|
|
||||||
# Find the latest version at or before the target datetime
|
|
||||||
# Versions are sorted by version number, not timestamp, so we need to check all
|
|
||||||
best_version = None
|
|
||||||
best_timestamp = None
|
|
||||||
|
|
||||||
for v in versions:
|
|
||||||
# LanceDB version timestamps are naive datetime objects in local time
|
|
||||||
v_timestamp = v["timestamp"]
|
|
||||||
# Make sure it's naive for comparison
|
|
||||||
if v_timestamp.tzinfo is not None:
|
|
||||||
v_timestamp = v_timestamp.replace(tzinfo=None)
|
|
||||||
|
|
||||||
if v_timestamp <= before_local:
|
|
||||||
if best_timestamp is None or v_timestamp > best_timestamp:
|
|
||||||
best_version = v["version"]
|
|
||||||
best_timestamp = v_timestamp
|
|
||||||
|
|
||||||
if best_version is None:
|
|
||||||
# Find the earliest version to report in error message
|
|
||||||
if versions:
|
|
||||||
earliest = min(versions, key=lambda v: v["timestamp"])
|
|
||||||
earliest_ts = earliest["timestamp"]
|
|
||||||
raise ValueError(
|
|
||||||
f"No data exists before {before}. "
|
|
||||||
f"Database was created on {earliest_ts}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise ValueError(
|
|
||||||
f"No data exists before {before}. Table has no versions."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Checkout to the found version
|
|
||||||
await table.checkout(best_version)
|
|
||||||
|
|
||||||
async def _checkout_tables_at_tag(self, name: str) -> None:
|
|
||||||
"""Checkout all tables at the version the tag points to.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If any table is missing the tag.
|
|
||||||
"""
|
|
||||||
for table_name, table in self._tables().items():
|
|
||||||
if name not in await table.tags.list():
|
|
||||||
raise ValueError(f"Tag '{name}' does not exist on table '{table_name}'")
|
|
||||||
await table.checkout(name)
|
|
||||||
|
|
||||||
async def list_table_versions(self, table_name: str) -> list[dict[str, Any]]:
|
async def list_table_versions(self, table_name: str) -> list[dict[str, Any]]:
|
||||||
"""List version history for a table.
|
"""List version history for a table.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import math
|
import math
|
||||||
import sys
|
import sys
|
||||||
from datetime import UTC, datetime
|
|
||||||
from importlib import metadata
|
from importlib import metadata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
from dateutil import parser as dateutil_parser
|
|
||||||
from packaging.version import Version, parse
|
from packaging.version import Version, parse
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -38,55 +36,6 @@ def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
|
||||||
return dot_product / (norm1 * norm2)
|
return dot_product / (norm1 * norm2)
|
||||||
|
|
||||||
|
|
||||||
def parse_datetime(s: str) -> datetime:
|
|
||||||
"""Parse a datetime string into a datetime object.
|
|
||||||
|
|
||||||
Supports:
|
|
||||||
- ISO 8601 format: "2025-01-15T14:30:00", "2025-01-15T14:30:00Z", "2025-01-15T14:30:00+00:00"
|
|
||||||
- Date only: "2025-01-15" (interpreted as 00:00:00)
|
|
||||||
- Various other formats via dateutil
|
|
||||||
|
|
||||||
Args:
|
|
||||||
s: String to parse
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Parsed datetime object
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If the string cannot be parsed
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return dateutil_parser.parse(s)
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
raise ValueError(
|
|
||||||
f"Could not parse datetime: {s}. "
|
|
||||||
"Use ISO 8601 format (e.g., 2025-01-15T14:30:00) or date (e.g., 2025-01-15)"
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
|
||||||
def to_utc(dt: datetime) -> datetime:
|
|
||||||
"""Convert a datetime to UTC.
|
|
||||||
|
|
||||||
- Naive datetimes are assumed to be local time and converted to UTC
|
|
||||||
- Datetimes with timezone info are converted to UTC
|
|
||||||
- UTC datetimes are returned as-is
|
|
||||||
|
|
||||||
Args:
|
|
||||||
dt: Datetime to convert
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Datetime in UTC timezone
|
|
||||||
"""
|
|
||||||
if dt.tzinfo is None:
|
|
||||||
# Naive datetime - assume local time
|
|
||||||
local_dt = dt.astimezone() # Adds local timezone
|
|
||||||
return local_dt.astimezone(UTC)
|
|
||||||
elif dt.tzinfo == UTC:
|
|
||||||
return dt
|
|
||||||
else:
|
|
||||||
return dt.astimezone(UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_common_settings(
|
def apply_common_settings(
|
||||||
settings: Any | None,
|
settings: Any | None,
|
||||||
settings_class: type[Any],
|
settings_class: type[Any],
|
||||||
|
|
|
||||||
|
|
@ -11,18 +11,14 @@ runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
def test_chat_command():
|
def test_chat_command():
|
||||||
"""Test chat command launches chat TUI with the global time-travel flags."""
|
"""Test chat command launches chat TUI."""
|
||||||
with patch("haiku.rag.chat.run_chat") as mock_chat:
|
with patch("haiku.rag.chat.run_chat") as mock_chat:
|
||||||
mock_chat.return_value = None
|
mock_chat.return_value = None
|
||||||
|
|
||||||
result = runner.invoke(cli, ["--at", "release-1", "chat"])
|
result = runner.invoke(cli, ["chat"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
mock_chat.assert_called_once()
|
mock_chat.assert_called_once()
|
||||||
kwargs = mock_chat.call_args.kwargs
|
|
||||||
assert kwargs["read_only"] is True
|
|
||||||
assert kwargs["before"] is None
|
|
||||||
assert kwargs["at_tag"] == "release-1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_chat_creates_app_and_runs(temp_db_path: Path):
|
def test_run_chat_creates_app_and_runs(temp_db_path: Path):
|
||||||
|
|
|
||||||
|
|
@ -218,3 +218,27 @@ async def test_tag_writes_raise_when_read_only(temp_db_path):
|
||||||
|
|
||||||
tags = await store.list_tags()
|
tags = await store.list_tags()
|
||||||
assert tags["release-1"].complete is True
|
assert tags["release-1"].complete is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_current_table_versions_returns_versions(temp_db_path):
|
||||||
|
"""current_table_versions returns dict of table versions."""
|
||||||
|
async with Store(temp_db_path, create=True) as store:
|
||||||
|
versions = await store.current_table_versions()
|
||||||
|
|
||||||
|
assert "documents" in versions
|
||||||
|
assert "chunks" in versions
|
||||||
|
assert "settings" in versions
|
||||||
|
assert all(isinstance(v, int) for v in versions.values())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_table_versions_returns_history(temp_db_path):
|
||||||
|
"""list_table_versions returns version history for a table."""
|
||||||
|
async with Store(temp_db_path, create=True) as store:
|
||||||
|
versions = await store.list_table_versions("documents")
|
||||||
|
|
||||||
|
assert len(versions) >= 1
|
||||||
|
for v in versions:
|
||||||
|
assert "version" in v
|
||||||
|
assert "timestamp" in v
|
||||||
|
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
import asyncio
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from haiku.rag.store import ReadOnlyError, Store
|
|
||||||
from haiku.rag.store.models import Document
|
|
||||||
from haiku.rag.store.repositories.document import DocumentRepository
|
|
||||||
|
|
||||||
|
|
||||||
class TestStoreTimeTravel:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_store_with_before_is_read_only(self, temp_db_path):
|
|
||||||
"""Store with before parameter is automatically read-only."""
|
|
||||||
async with Store(temp_db_path, create=True):
|
|
||||||
pass
|
|
||||||
|
|
||||||
before = datetime.now(UTC) + timedelta(hours=1)
|
|
||||||
async with Store(temp_db_path, before=before) as store:
|
|
||||||
assert store.is_read_only is True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_store_before_raises_on_write(self, temp_db_path):
|
|
||||||
"""Store with before parameter raises on write operations."""
|
|
||||||
async with Store(temp_db_path, create=True):
|
|
||||||
pass
|
|
||||||
|
|
||||||
before = datetime.now(UTC) + timedelta(hours=1)
|
|
||||||
async with Store(temp_db_path, before=before) as store:
|
|
||||||
with pytest.raises(ReadOnlyError):
|
|
||||||
store._assert_writable()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_store_before_checks_out_historical_state(self, temp_db_path):
|
|
||||||
"""Store with before parameter checks out tables to historical state."""
|
|
||||||
async with Store(temp_db_path, create=True) as store:
|
|
||||||
repo = DocumentRepository(store)
|
|
||||||
await repo.create(Document(content="First document"))
|
|
||||||
|
|
||||||
versions_after_first = await store.list_table_versions("documents")
|
|
||||||
latest_version = max(versions_after_first, key=lambda v: v["version"])
|
|
||||||
time_after_first = latest_version["timestamp"]
|
|
||||||
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
|
|
||||||
await repo.create(Document(content="Second document"))
|
|
||||||
|
|
||||||
versions_after_second = await store.list_table_versions("documents")
|
|
||||||
assert len(versions_after_second) > len(versions_after_first)
|
|
||||||
|
|
||||||
async with Store(temp_db_path, before=time_after_first) as store:
|
|
||||||
repo = DocumentRepository(store)
|
|
||||||
|
|
||||||
docs = await repo.list_all(include_content=True)
|
|
||||||
assert len(docs) == 1
|
|
||||||
assert docs[0].content == "First document"
|
|
||||||
|
|
||||||
async with Store(temp_db_path) as store:
|
|
||||||
repo = DocumentRepository(store)
|
|
||||||
|
|
||||||
docs = await repo.list_all()
|
|
||||||
assert len(docs) == 2
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_store_before_no_version_raises(self, temp_db_path):
|
|
||||||
"""Store with before datetime before any version raises ValueError."""
|
|
||||||
async with Store(temp_db_path, create=True):
|
|
||||||
pass
|
|
||||||
|
|
||||||
before = datetime(2000, 1, 1, tzinfo=UTC)
|
|
||||||
with pytest.raises(ValueError) as exc_info:
|
|
||||||
async with Store(temp_db_path, before=before):
|
|
||||||
pass
|
|
||||||
assert "No data exists before" in str(exc_info.value)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_store_with_at_tag_is_read_only(self, temp_db_path):
|
|
||||||
"""Store with at_tag parameter is automatically read-only."""
|
|
||||||
async with Store(temp_db_path, create=True) as store:
|
|
||||||
await store.create_tag("release-1")
|
|
||||||
|
|
||||||
async with Store(temp_db_path, at_tag="release-1") as store:
|
|
||||||
assert store.is_read_only is True
|
|
||||||
with pytest.raises(ReadOnlyError):
|
|
||||||
store._assert_writable()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_store_at_tag_checks_out_tagged_state(self, temp_db_path):
|
|
||||||
"""Store with at_tag checks out every table at the tagged version."""
|
|
||||||
async with Store(temp_db_path, create=True) as store:
|
|
||||||
repo = DocumentRepository(store)
|
|
||||||
await repo.create(Document(content="First document"))
|
|
||||||
await store.create_tag("release-1")
|
|
||||||
await repo.create(Document(content="Second document"))
|
|
||||||
|
|
||||||
async with Store(temp_db_path, at_tag="release-1") as store:
|
|
||||||
docs = await DocumentRepository(store).list_all(include_content=True)
|
|
||||||
assert len(docs) == 1
|
|
||||||
assert docs[0].content == "First document"
|
|
||||||
|
|
||||||
async with Store(temp_db_path) as store:
|
|
||||||
docs = await DocumentRepository(store).list_all()
|
|
||||||
assert len(docs) == 2
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_store_at_tag_unknown_raises(self, temp_db_path):
|
|
||||||
async with Store(temp_db_path, create=True):
|
|
||||||
pass
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="nope"):
|
|
||||||
async with Store(temp_db_path, at_tag="nope"):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_store_at_tag_partial_raises_naming_table(self, temp_db_path):
|
|
||||||
"""A tag missing from some tables fails with the table named."""
|
|
||||||
async with Store(temp_db_path, create=True) as store:
|
|
||||||
version = await store.chunks_table.version()
|
|
||||||
await store.chunks_table.tags.create("stale", version)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="documents"):
|
|
||||||
async with Store(temp_db_path, at_tag="stale"):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_store_at_tag_and_before_mutually_exclusive(self, temp_db_path):
|
|
||||||
async with Store(temp_db_path, create=True) as store:
|
|
||||||
await store.create_tag("release-1")
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
|
||||||
Store(
|
|
||||||
temp_db_path,
|
|
||||||
at_tag="release-1",
|
|
||||||
before=datetime.now(UTC),
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_current_table_versions_returns_versions(self, temp_db_path):
|
|
||||||
"""current_table_versions returns dict of table versions."""
|
|
||||||
async with Store(temp_db_path, create=True) as store:
|
|
||||||
versions = await store.current_table_versions()
|
|
||||||
|
|
||||||
assert "documents" in versions
|
|
||||||
assert "chunks" in versions
|
|
||||||
assert "settings" in versions
|
|
||||||
assert all(isinstance(v, int) for v in versions.values())
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_table_versions_returns_history(self, temp_db_path):
|
|
||||||
"""list_table_versions returns version history for a table."""
|
|
||||||
async with Store(temp_db_path, create=True) as store:
|
|
||||||
versions = await store.list_table_versions("documents")
|
|
||||||
|
|
||||||
assert len(versions) >= 1
|
|
||||||
for v in versions:
|
|
||||||
assert "version" in v
|
|
||||||
assert "timestamp" in v
|
|
||||||
|
|
@ -153,21 +153,6 @@ class TestTagCommands:
|
||||||
|
|
||||||
assert "document_meta" not in asyncio.run(_table_names())
|
assert "document_meta" not in asyncio.run(_table_names())
|
||||||
|
|
||||||
def test_tag_write_commands_reject_time_travel(self, temp_db_path):
|
|
||||||
db = str(temp_db_path)
|
|
||||||
result = runner.invoke(cli, ["init", "--db", db])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
result = runner.invoke(cli, ["--at", "x", "tag", "create", "r1", "--db", db])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "--at" in result.output
|
|
||||||
|
|
||||||
result = runner.invoke(
|
|
||||||
cli, ["--before", "2025-01-01", "tag", "delete", "r1", "--db", db]
|
|
||||||
)
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "--before" in result.output
|
|
||||||
|
|
||||||
def test_tag_create_invalid_name_fails_cleanly(self, temp_db_path):
|
def test_tag_create_invalid_name_fails_cleanly(self, temp_db_path):
|
||||||
"""lance restricts ref names to alphanumeric, '.', '-', '_'; the CLI
|
"""lance restricts ref names to alphanumeric, '.', '-', '_'; the CLI
|
||||||
surfaces that as a clean error instead of a traceback."""
|
surfaces that as a clean error instead of a traceback."""
|
||||||
|
|
|
||||||
|
|
@ -2264,32 +2264,3 @@ async def test_rebuild_rechunk_with_url_prefixed_stored_content(
|
||||||
assert doc_after is not None
|
assert doc_after is not None
|
||||||
assert "example.com" in doc_after.content
|
assert "example.com" in doc_after.content
|
||||||
assert "Stored" in doc_after.content
|
assert "Stored" in doc_after.content
|
||||||
|
|
||||||
|
|
||||||
async def test_client_at_tag_opens_tagged_state_read_only(temp_db_path):
|
|
||||||
"""HaikuRAG(at_tag=...) opens the database read-only at the tagged state."""
|
|
||||||
dim = Config.embeddings.model.vector_dim
|
|
||||||
|
|
||||||
def _doc(name: str, text: str) -> DoclingDocument:
|
|
||||||
doc = DoclingDocument(name=name)
|
|
||||||
doc.add_text(label=DocItemLabel.TEXT, text=text)
|
|
||||||
return doc
|
|
||||||
|
|
||||||
async with HaikuRAG(temp_db_path, create=True) as client:
|
|
||||||
await client.import_document(
|
|
||||||
_doc("first", "First document"),
|
|
||||||
[Chunk(content="First document", embedding=[0.1] * dim, order=0)],
|
|
||||||
uri="mem://first",
|
|
||||||
)
|
|
||||||
await client.store.create_tag("release-1")
|
|
||||||
await client.import_document(
|
|
||||||
_doc("second", "Second document"),
|
|
||||||
[Chunk(content="Second document", embedding=[0.1] * dim, order=0)],
|
|
||||||
uri="mem://second",
|
|
||||||
)
|
|
||||||
|
|
||||||
async with HaikuRAG(temp_db_path, at_tag="release-1") as client:
|
|
||||||
assert client.is_read_only is True
|
|
||||||
docs = await client.list_documents()
|
|
||||||
assert len(docs) == 1
|
|
||||||
assert docs[0].uri == "mem://first"
|
|
||||||
|
|
|
||||||
|
|
@ -267,47 +267,3 @@ class TestMCPImageQuery:
|
||||||
# in search_documents_by_image rejects it.
|
# in search_documents_by_image rejects it.
|
||||||
results = await search_by_image(image_base64="!!! not base64 !!!")
|
results = await search_by_image(image_base64="!!! not base64 !!!")
|
||||||
assert results == []
|
assert results == []
|
||||||
|
|
||||||
|
|
||||||
class TestMCPTimeTravel:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_at_tag_disables_write_tools(self, mcp_db):
|
|
||||||
async with HaikuRAG(mcp_db) as rag:
|
|
||||||
await rag.store.create_tag("release-1")
|
|
||||||
|
|
||||||
mcp = create_mcp_server(mcp_db, at_tag="release-1")
|
|
||||||
tool_names = [t.name for t in await mcp.list_tools()]
|
|
||||||
assert "add_document_from_text" not in tool_names
|
|
||||||
assert "delete_document" not in tool_names
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_before_disables_write_tools(self, mcp_db):
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
mcp = create_mcp_server(mcp_db, before=datetime.now(UTC))
|
|
||||||
tool_names = [t.name for t in await mcp.list_tools()]
|
|
||||||
assert "add_document_from_text" not in tool_names
|
|
||||||
assert "delete_document" not in tool_names
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_at_tag_serves_tagged_state(self, mcp_db):
|
|
||||||
"""Read tools on a tagged server must see the tagged state, not the
|
|
||||||
live database."""
|
|
||||||
async with HaikuRAG(mcp_db) as rag:
|
|
||||||
await rag.store.create_tag("release-1")
|
|
||||||
await rag.create_document(
|
|
||||||
"A document added after the tag.",
|
|
||||||
title="Post-tag Doc",
|
|
||||||
uri="test://post-tag",
|
|
||||||
)
|
|
||||||
|
|
||||||
mcp = create_mcp_server(mcp_db, at_tag="release-1")
|
|
||||||
list_docs = await _get_tool(mcp, "list_documents")
|
|
||||||
docs = await list_docs()
|
|
||||||
uris = {d.uri for d in docs}
|
|
||||||
assert "test://post-tag" not in uris
|
|
||||||
assert len(docs) == 2
|
|
||||||
|
|
||||||
mcp_live = create_mcp_server(mcp_db, read_only=True)
|
|
||||||
list_docs = await _get_tool(mcp_live, "list_documents")
|
|
||||||
assert len(await list_docs()) == 3
|
|
||||||
|
|
|
||||||
|
|
@ -382,79 +382,6 @@ def test_get_package_versions():
|
||||||
assert len(value) > 0
|
assert len(value) > 0
|
||||||
|
|
||||||
|
|
||||||
# --- parse_datetime tests ---
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_datetime_iso8601():
|
|
||||||
from haiku.rag.utils import parse_datetime
|
|
||||||
|
|
||||||
dt = parse_datetime("2025-01-15T14:30:00")
|
|
||||||
assert dt.year == 2025
|
|
||||||
assert dt.month == 1
|
|
||||||
assert dt.day == 15
|
|
||||||
assert dt.hour == 14
|
|
||||||
assert dt.minute == 30
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_datetime_date_only():
|
|
||||||
from haiku.rag.utils import parse_datetime
|
|
||||||
|
|
||||||
dt = parse_datetime("2025-01-15")
|
|
||||||
assert dt.year == 2025
|
|
||||||
assert dt.month == 1
|
|
||||||
assert dt.day == 15
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_datetime_with_timezone():
|
|
||||||
from haiku.rag.utils import parse_datetime
|
|
||||||
|
|
||||||
dt = parse_datetime("2025-01-15T14:30:00+00:00")
|
|
||||||
assert dt.year == 2025
|
|
||||||
assert dt.tzinfo is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_datetime_invalid():
|
|
||||||
from haiku.rag.utils import parse_datetime
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Could not parse datetime"):
|
|
||||||
parse_datetime("not-a-date")
|
|
||||||
|
|
||||||
|
|
||||||
# --- to_utc tests ---
|
|
||||||
|
|
||||||
|
|
||||||
def test_to_utc_naive_datetime():
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from haiku.rag.utils import to_utc
|
|
||||||
|
|
||||||
naive = datetime(2025, 6, 15, 12, 0, 0)
|
|
||||||
result = to_utc(naive)
|
|
||||||
assert result.tzinfo is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_to_utc_utc_datetime():
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
from haiku.rag.utils import to_utc
|
|
||||||
|
|
||||||
utc_dt = datetime(2025, 6, 15, 12, 0, 0, tzinfo=UTC)
|
|
||||||
result = to_utc(utc_dt)
|
|
||||||
assert result is utc_dt
|
|
||||||
|
|
||||||
|
|
||||||
def test_to_utc_aware_non_utc():
|
|
||||||
from datetime import UTC, datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from haiku.rag.utils import to_utc
|
|
||||||
|
|
||||||
eastern = timezone(timedelta(hours=-5))
|
|
||||||
aware = datetime(2025, 6, 15, 12, 0, 0, tzinfo=eastern)
|
|
||||||
result = to_utc(aware)
|
|
||||||
assert result.tzinfo == UTC
|
|
||||||
assert result.hour == 17
|
|
||||||
|
|
||||||
|
|
||||||
# --- apply_common_settings tests ---
|
# --- apply_common_settings tests ---
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
from datetime import UTC, datetime, timezone
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from haiku.rag.utils import parse_datetime, to_utc
|
|
||||||
|
|
||||||
|
|
||||||
class TestParseDateTime:
|
|
||||||
def test_parse_iso8601_with_timezone(self):
|
|
||||||
"""Parse ISO 8601 datetime with timezone."""
|
|
||||||
result = parse_datetime("2025-01-15T14:30:00+00:00")
|
|
||||||
assert result.year == 2025
|
|
||||||
assert result.month == 1
|
|
||||||
assert result.day == 15
|
|
||||||
assert result.hour == 14
|
|
||||||
assert result.minute == 30
|
|
||||||
assert result.second == 0
|
|
||||||
assert result.tzinfo is not None
|
|
||||||
|
|
||||||
def test_parse_iso8601_without_timezone(self):
|
|
||||||
"""Parse ISO 8601 datetime without timezone (naive)."""
|
|
||||||
result = parse_datetime("2025-01-15T14:30:00")
|
|
||||||
assert result.year == 2025
|
|
||||||
assert result.month == 1
|
|
||||||
assert result.day == 15
|
|
||||||
assert result.hour == 14
|
|
||||||
assert result.minute == 30
|
|
||||||
|
|
||||||
def test_parse_date_only(self):
|
|
||||||
"""Parse date-only string as start of day."""
|
|
||||||
result = parse_datetime("2025-01-15")
|
|
||||||
assert result.year == 2025
|
|
||||||
assert result.month == 1
|
|
||||||
assert result.day == 15
|
|
||||||
assert result.hour == 0
|
|
||||||
assert result.minute == 0
|
|
||||||
assert result.second == 0
|
|
||||||
|
|
||||||
def test_parse_various_formats(self):
|
|
||||||
"""Parse various datetime formats."""
|
|
||||||
# ISO with Z suffix
|
|
||||||
result = parse_datetime("2025-01-15T14:30:00Z")
|
|
||||||
assert result.year == 2025
|
|
||||||
assert result.month == 1
|
|
||||||
assert result.day == 15
|
|
||||||
|
|
||||||
# With milliseconds
|
|
||||||
result = parse_datetime("2025-01-15T14:30:00.123")
|
|
||||||
assert result.microsecond == 123000
|
|
||||||
|
|
||||||
def test_parse_invalid_raises_value_error(self):
|
|
||||||
"""Invalid datetime string raises ValueError."""
|
|
||||||
with pytest.raises(ValueError) as exc_info:
|
|
||||||
parse_datetime("not-a-datetime")
|
|
||||||
assert "Could not parse datetime" in str(exc_info.value)
|
|
||||||
|
|
||||||
|
|
||||||
class TestToUtc:
|
|
||||||
def test_naive_datetime_assumes_local_and_converts(self):
|
|
||||||
"""Naive datetime is assumed local and converted to UTC."""
|
|
||||||
naive = datetime(2025, 1, 15, 14, 30, 0)
|
|
||||||
result = to_utc(naive)
|
|
||||||
assert result.tzinfo == UTC
|
|
||||||
|
|
||||||
def test_utc_datetime_unchanged(self):
|
|
||||||
"""UTC datetime is returned as-is."""
|
|
||||||
utc_dt = datetime(2025, 1, 15, 14, 30, 0, tzinfo=UTC)
|
|
||||||
result = to_utc(utc_dt)
|
|
||||||
assert result == utc_dt
|
|
||||||
assert result.tzinfo == UTC
|
|
||||||
|
|
||||||
def test_other_timezone_converts_to_utc(self):
|
|
||||||
"""Datetime with other timezone is converted to UTC."""
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
# Create a datetime at UTC+5
|
|
||||||
tz_plus5 = timezone(timedelta(hours=5))
|
|
||||||
dt_plus5 = datetime(2025, 1, 15, 19, 30, 0, tzinfo=tz_plus5)
|
|
||||||
|
|
||||||
result = to_utc(dt_plus5)
|
|
||||||
|
|
||||||
# 19:30 UTC+5 = 14:30 UTC
|
|
||||||
assert result.tzinfo == UTC
|
|
||||||
assert result.hour == 14
|
|
||||||
assert result.minute == 30
|
|
||||||
Loading…
Reference in a new issue