docpybara 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docpybara-0.1.0/PKG-INFO +26 -0
- docpybara-0.1.0/README.md +168 -0
- docpybara-0.1.0/pyproject.toml +69 -0
- docpybara-0.1.0/setup.cfg +4 -0
- docpybara-0.1.0/src/docpybara/__init__.py +3 -0
- docpybara-0.1.0/src/docpybara/cli.py +36 -0
- docpybara-0.1.0/src/docpybara/routers/__init__.py +0 -0
- docpybara-0.1.0/src/docpybara/routers/ai.py +39 -0
- docpybara-0.1.0/src/docpybara/routers/files.py +48 -0
- docpybara-0.1.0/src/docpybara/routers/search.py +15 -0
- docpybara-0.1.0/src/docpybara/server.py +30 -0
- docpybara-0.1.0/src/docpybara/services/__init__.py +0 -0
- docpybara-0.1.0/src/docpybara/services/ai.py +55 -0
- docpybara-0.1.0/src/docpybara/services/filesystem.py +84 -0
- docpybara-0.1.0/src/docpybara/services/search.py +61 -0
- docpybara-0.1.0/src/docpybara/static/css/style.css +222 -0
- docpybara-0.1.0/src/docpybara/static/index.html +48 -0
- docpybara-0.1.0/src/docpybara/static/js/ai.js +171 -0
- docpybara-0.1.0/src/docpybara/static/js/app.js +247 -0
- docpybara-0.1.0/src/docpybara/static/js/cm-bundle.js +29 -0
- docpybara-0.1.0/src/docpybara/static/js/editor.js +118 -0
- docpybara-0.1.0/src/docpybara/static/js/tree.js +54 -0
- docpybara-0.1.0/src/docpybara.egg-info/PKG-INFO +26 -0
- docpybara-0.1.0/src/docpybara.egg-info/SOURCES.txt +30 -0
- docpybara-0.1.0/src/docpybara.egg-info/dependency_links.txt +1 -0
- docpybara-0.1.0/src/docpybara.egg-info/entry_points.txt +2 -0
- docpybara-0.1.0/src/docpybara.egg-info/requires.txt +13 -0
- docpybara-0.1.0/src/docpybara.egg-info/top_level.txt +1 -0
- docpybara-0.1.0/tests/test_ai.py +41 -0
- docpybara-0.1.0/tests/test_files_api.py +39 -0
- docpybara-0.1.0/tests/test_filesystem.py +71 -0
- docpybara-0.1.0/tests/test_search.py +38 -0
docpybara-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: docpybara
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight web-based markdown document manager for your repository
|
|
5
|
+
Author: mjk
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: markdown,docs,editor,web
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Framework :: FastAPI
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: fastapi>=0.111.0
|
|
17
|
+
Requires-Dist: uvicorn>=0.23.0
|
|
18
|
+
Requires-Dist: click>=8.0.0
|
|
19
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
20
|
+
Provides-Extra: ai
|
|
21
|
+
Requires-Dist: openai>=1.0.0; extra == "ai"
|
|
22
|
+
Requires-Dist: anthropic>=0.20.0; extra == "ai"
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
25
|
+
Requires-Dist: httpx>=0.24.0; extra == "dev"
|
|
26
|
+
Requires-Dist: ruff>=0.4.0; extra == "dev"
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# docpybara
|
|
2
|
+
|
|
3
|
+
A lightweight, pip-installable web-based markdown document manager. Point it at any directory and manage your `.md` files through a clean dark-themed web UI with AI-powered text refinement.
|
|
4
|
+
|
|
5
|
+
Built as a final project for Stanford CS146S: The Modern Software Developer.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **File tree** — Browse `.md` files in a collapsible sidebar tree view
|
|
10
|
+
- **CodeMirror 6 editor** — Syntax-highlighted markdown editing with formatting toolbar (bold, italic, heading, link, list)
|
|
11
|
+
- **Full CRUD** — Create, read, update, and delete markdown files from the browser
|
|
12
|
+
- **Server-side search** — Search by filename and content with context snippets
|
|
13
|
+
- **AI text refinement** — Select text and refine it with OpenAI gpt-5-mini (concise, fix grammar, translate, change tone, or custom instructions)
|
|
14
|
+
- **Save feedback** — Visual toast notifications and status indicators on save
|
|
15
|
+
- **Keyboard shortcuts** — Ctrl/Cmd+S to save
|
|
16
|
+
- **Zero config** — `pip install` and run, no build step or database needed
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
### Prerequisites
|
|
21
|
+
- Python 3.10+
|
|
22
|
+
- (Optional) OpenAI API key for AI features
|
|
23
|
+
|
|
24
|
+
### Installation & Run
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# Install
|
|
28
|
+
pip install -e ".[ai]"
|
|
29
|
+
|
|
30
|
+
# Create a .env file with your OpenAI key (optional, for AI features)
|
|
31
|
+
cp .env.example .env
|
|
32
|
+
# Edit .env and add your OPENAI_API_KEY
|
|
33
|
+
|
|
34
|
+
# Serve a directory
|
|
35
|
+
docpybara --dir ./docs
|
|
36
|
+
|
|
37
|
+
# Open http://localhost:8000 in your browser
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### CLI Options
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
docpybara [OPTIONS]
|
|
44
|
+
|
|
45
|
+
Options:
|
|
46
|
+
--dir TEXT Root directory to serve (default: current directory)
|
|
47
|
+
--port INTEGER Port number (default: 8000)
|
|
48
|
+
--host TEXT Host to bind to (default: 0.0.0.0)
|
|
49
|
+
--reload Enable auto-reload for development
|
|
50
|
+
--help Show help message
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Development Commands (Makefile)
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
make install # pip install -e ".[dev,ai]"
|
|
57
|
+
make run # Start server (port 8000)
|
|
58
|
+
make dev # Start with auto-reload
|
|
59
|
+
make test # Run pytest
|
|
60
|
+
make lint # Run ruff check
|
|
61
|
+
make format # Run ruff --fix
|
|
62
|
+
make build # Build package (dist/)
|
|
63
|
+
make clean # Remove build artifacts
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## AI Features
|
|
67
|
+
|
|
68
|
+
AI text refinement uses OpenAI gpt-5-mini. Set your API key in `.env`:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
OPENAI_API_KEY=sk-...
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Usage
|
|
75
|
+
1. Open a file in the editor
|
|
76
|
+
2. Select text you want to refine
|
|
77
|
+
3. Click the **AI** button in the toolbar
|
|
78
|
+
4. Choose a preset or type a custom instruction
|
|
79
|
+
5. Preview the result and click **Apply**
|
|
80
|
+
|
|
81
|
+
### Presets
|
|
82
|
+
|
|
83
|
+
| Preset | Description |
|
|
84
|
+
|--------|-------------|
|
|
85
|
+
| Concise | Make text more concise |
|
|
86
|
+
| Fix grammar | Fix spelling and punctuation |
|
|
87
|
+
| English | Translate to English |
|
|
88
|
+
| Korean | Translate to Korean |
|
|
89
|
+
| Formal | Professional tone |
|
|
90
|
+
| Casual | Friendly tone |
|
|
91
|
+
|
|
92
|
+
## Architecture
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
docpybara/
|
|
96
|
+
├── pyproject.toml # Package metadata + CLI entry point
|
|
97
|
+
├── Makefile # Dev commands
|
|
98
|
+
├── src/docpybara/
|
|
99
|
+
│ ├── __init__.py # Version
|
|
100
|
+
│ ├── cli.py # Click CLI + .env loading
|
|
101
|
+
│ ├── server.py # FastAPI app factory
|
|
102
|
+
│ ├── routers/
|
|
103
|
+
│ │ ├── files.py # File CRUD (GET/POST/PUT/DELETE)
|
|
104
|
+
│ │ ├── search.py # Search endpoint
|
|
105
|
+
│ │ └── ai.py # AI refinement endpoint
|
|
106
|
+
│ ├── services/
|
|
107
|
+
│ │ ├── filesystem.py # File I/O + path traversal protection
|
|
108
|
+
│ │ ├── search.py # Filename + content search
|
|
109
|
+
│ │ └── ai.py # OpenAI gpt-5-mini integration
|
|
110
|
+
│ └── static/
|
|
111
|
+
│ ├── index.html # SPA entry point
|
|
112
|
+
│ ├── css/style.css # Dark theme (Catppuccin-inspired)
|
|
113
|
+
│ └── js/
|
|
114
|
+
│ ├── app.js # Main app logic + toast notifications
|
|
115
|
+
│ ├── tree.js # File tree component
|
|
116
|
+
│ ├── editor.js # CodeMirror 6 wrapper
|
|
117
|
+
│ ├── ai.js # AI refinement dialog UI
|
|
118
|
+
│ └── cm-bundle.js # Pre-built CodeMirror bundle
|
|
119
|
+
└── tests/ # 25 tests (pytest)
|
|
120
|
+
├── conftest.py
|
|
121
|
+
├── test_filesystem.py
|
|
122
|
+
├── test_files_api.py
|
|
123
|
+
├── test_search.py
|
|
124
|
+
└── test_ai.py
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Tech Stack
|
|
128
|
+
|
|
129
|
+
| Layer | Technology |
|
|
130
|
+
|-------|-----------|
|
|
131
|
+
| Backend | FastAPI + uvicorn |
|
|
132
|
+
| Frontend | Vanilla JS + CodeMirror 6 (pre-built bundle) |
|
|
133
|
+
| CLI | Click + python-dotenv |
|
|
134
|
+
| AI | OpenAI SDK (gpt-5-mini) |
|
|
135
|
+
| Testing | pytest + httpx |
|
|
136
|
+
| Linting | ruff (E, F, I, UP, B, SIM, RUF) |
|
|
137
|
+
|
|
138
|
+
### API Endpoints
|
|
139
|
+
|
|
140
|
+
| Method | Path | Description |
|
|
141
|
+
|--------|------|-------------|
|
|
142
|
+
| GET | `/api/tree` | File tree |
|
|
143
|
+
| GET | `/api/files/{path}` | Read file |
|
|
144
|
+
| PUT | `/api/files/{path}` | Update file |
|
|
145
|
+
| POST | `/api/files/{path}` | Create file |
|
|
146
|
+
| DELETE | `/api/files/{path}` | Delete file |
|
|
147
|
+
| GET | `/api/search?q=` | Search files |
|
|
148
|
+
| POST | `/api/ai/refine` | AI text refinement |
|
|
149
|
+
| GET | `/api/ai/presets` | List AI presets |
|
|
150
|
+
|
|
151
|
+
## Design Decisions
|
|
152
|
+
|
|
153
|
+
- **No build step for frontend** — CodeMirror is pre-bundled via esbuild and shipped as a static asset. No npm/webpack needed at runtime.
|
|
154
|
+
- **File-system backed** — No database. Files are read/written directly to disk. What you see in the UI is what's on disk.
|
|
155
|
+
- **Path traversal protection** — All file paths are resolved and validated against the root directory before any I/O operation.
|
|
156
|
+
- **AI as optional** — Core editing works without any API key. AI features show a clear error message when no key is configured.
|
|
157
|
+
- **pip-installable** — Single `pip install` gives you a CLI tool. No Docker, no config files required.
|
|
158
|
+
- **Dark theme** — Catppuccin-inspired color scheme for comfortable long-session editing.
|
|
159
|
+
|
|
160
|
+
## Security
|
|
161
|
+
|
|
162
|
+
- Path traversal attacks are blocked by resolving all paths and verifying they stay within the configured root directory
|
|
163
|
+
- API keys are loaded from environment variables or `.env` files, never exposed to the frontend
|
|
164
|
+
- The AI refinement endpoint proxies requests through the server so client never sees the API key
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "docpybara"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A lightweight web-based markdown document manager for your repository"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "mjk" }]
|
|
12
|
+
keywords = ["markdown", "docs", "editor", "web"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Framework :: FastAPI",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"fastapi>=0.111.0",
|
|
24
|
+
"uvicorn>=0.23.0",
|
|
25
|
+
"click>=8.0.0",
|
|
26
|
+
"python-dotenv>=1.0.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
ai = [
|
|
31
|
+
"openai>=1.0.0",
|
|
32
|
+
"anthropic>=0.20.0",
|
|
33
|
+
]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=7.0.0",
|
|
36
|
+
"httpx>=0.24.0",
|
|
37
|
+
"ruff>=0.4.0",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
docpybara = "docpybara.cli:main"
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.packages.find]
|
|
44
|
+
where = ["src"]
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.package-data]
|
|
47
|
+
docpybara = ["static/**/*"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
line-length = 100
|
|
51
|
+
target-version = "py310"
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint]
|
|
54
|
+
select = [
|
|
55
|
+
"E", # pycodestyle errors
|
|
56
|
+
"F", # pyflakes
|
|
57
|
+
"I", # isort
|
|
58
|
+
"UP", # pyupgrade
|
|
59
|
+
"B", # flake8-bugbear
|
|
60
|
+
"SIM", # flake8-simplify
|
|
61
|
+
"RUF", # ruff-specific rules
|
|
62
|
+
]
|
|
63
|
+
ignore = ["E501"]
|
|
64
|
+
|
|
65
|
+
[tool.ruff.lint.isort]
|
|
66
|
+
known-first-party = ["docpybara"]
|
|
67
|
+
|
|
68
|
+
[tool.pytest.ini_options]
|
|
69
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""CLI entry point for docpybara."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import uvicorn
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@click.command()
|
|
8
|
+
@click.option("--dir", "root_dir", default=".", help="Root directory to serve markdown files from")
|
|
9
|
+
@click.option("--port", default=8000, help="Port to run the server on")
|
|
10
|
+
@click.option("--host", default="0.0.0.0", help="Host to bind the server to")
|
|
11
|
+
@click.option("--reload", is_flag=True, help="Enable auto-reload for development")
|
|
12
|
+
def main(root_dir: str, port: int, host: str, reload: bool) -> None:
|
|
13
|
+
"""docpybara - A lightweight web-based markdown document manager."""
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
# Load .env from the root_dir or current directory
|
|
18
|
+
for env_path in [Path(root_dir) / ".env", Path(".env")]:
|
|
19
|
+
if env_path.is_file():
|
|
20
|
+
from dotenv import load_dotenv
|
|
21
|
+
|
|
22
|
+
load_dotenv(env_path)
|
|
23
|
+
break
|
|
24
|
+
|
|
25
|
+
os.environ["DOCPYBARA_ROOT_DIR"] = os.path.abspath(root_dir)
|
|
26
|
+
|
|
27
|
+
click.echo(f"docpybara v0.1.0 serving '{os.path.abspath(root_dir)}'")
|
|
28
|
+
click.echo(f"Open http://{host}:{port} in your browser")
|
|
29
|
+
|
|
30
|
+
uvicorn.run(
|
|
31
|
+
"docpybara.server:create_app",
|
|
32
|
+
factory=True,
|
|
33
|
+
host=host,
|
|
34
|
+
port=port,
|
|
35
|
+
reload=reload,
|
|
36
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""AI text refinement API endpoint."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from docpybara.services.ai import PRESETS, refine_text
|
|
7
|
+
|
|
8
|
+
router = APIRouter(tags=["ai"])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RefineRequest(BaseModel):
|
|
12
|
+
text: str
|
|
13
|
+
instruction: str = ""
|
|
14
|
+
preset: str | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RefineResponse(BaseModel):
|
|
18
|
+
refined: str
|
|
19
|
+
model: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.post("/ai/refine", response_model=RefineResponse)
|
|
23
|
+
async def api_refine(body: RefineRequest) -> RefineResponse:
|
|
24
|
+
"""Refine selected text using AI."""
|
|
25
|
+
if not body.text.strip():
|
|
26
|
+
raise HTTPException(status_code=400, detail="Text cannot be empty")
|
|
27
|
+
try:
|
|
28
|
+
result = await refine_text(body.text, body.instruction, body.preset)
|
|
29
|
+
return RefineResponse(**result)
|
|
30
|
+
except ValueError as e:
|
|
31
|
+
raise HTTPException(status_code=422, detail=str(e)) from None
|
|
32
|
+
except Exception as e:
|
|
33
|
+
raise HTTPException(status_code=502, detail=f"AI service error: {e}") from None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.get("/ai/presets")
|
|
37
|
+
async def api_presets() -> dict[str, str]:
|
|
38
|
+
"""Get available refinement presets."""
|
|
39
|
+
return PRESETS
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""File management API endpoints."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Request
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from docpybara.services.filesystem import create_file, delete_file, get_tree, read_file, write_file
|
|
9
|
+
|
|
10
|
+
router = APIRouter(tags=["files"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FileContent(BaseModel):
|
|
14
|
+
content: str = ""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.get("/tree")
|
|
18
|
+
async def api_get_tree(request: Request) -> list[dict[str, Any]]:
|
|
19
|
+
"""Get the markdown file tree."""
|
|
20
|
+
return get_tree(request.app.state.root_dir)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.get("/files/{path:path}")
|
|
24
|
+
async def api_read_file(path: str, request: Request) -> dict[str, str]:
|
|
25
|
+
"""Read a markdown file."""
|
|
26
|
+
content = read_file(request.app.state.root_dir, path)
|
|
27
|
+
return {"path": path, "content": content}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.put("/files/{path:path}")
|
|
31
|
+
async def api_write_file(path: str, body: FileContent, request: Request) -> dict[str, str]:
|
|
32
|
+
"""Update a markdown file."""
|
|
33
|
+
write_file(request.app.state.root_dir, path, body.content)
|
|
34
|
+
return {"path": path, "status": "saved"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.post("/files/{path:path}", status_code=201)
|
|
38
|
+
async def api_create_file(path: str, body: FileContent, request: Request) -> dict[str, str]:
|
|
39
|
+
"""Create a new markdown file."""
|
|
40
|
+
create_file(request.app.state.root_dir, path, body.content)
|
|
41
|
+
return {"path": path, "status": "created"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@router.delete("/files/{path:path}")
|
|
45
|
+
async def api_delete_file(path: str, request: Request) -> dict[str, str]:
|
|
46
|
+
"""Delete a markdown file."""
|
|
47
|
+
delete_file(request.app.state.root_dir, path)
|
|
48
|
+
return {"path": path, "status": "deleted"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Search API endpoint."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Request
|
|
6
|
+
|
|
7
|
+
from docpybara.services.search import search_files
|
|
8
|
+
|
|
9
|
+
router = APIRouter(tags=["search"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.get("/search")
|
|
13
|
+
async def api_search(q: str, request: Request) -> list[dict[str, Any]]:
|
|
14
|
+
"""Search markdown files by filename and content."""
|
|
15
|
+
return search_files(request.app.state.root_dir, q)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""FastAPI application factory."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
from fastapi.staticfiles import StaticFiles
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_app() -> FastAPI:
|
|
11
|
+
"""Create and configure the FastAPI application."""
|
|
12
|
+
app = FastAPI(title="docpybara", version="0.1.0")
|
|
13
|
+
|
|
14
|
+
root_dir = os.environ.get("DOCPYBARA_ROOT_DIR", ".")
|
|
15
|
+
app.state.root_dir = Path(root_dir).resolve()
|
|
16
|
+
|
|
17
|
+
# Register routers
|
|
18
|
+
from docpybara.routers.ai import router as ai_router
|
|
19
|
+
from docpybara.routers.files import router as files_router
|
|
20
|
+
from docpybara.routers.search import router as search_router
|
|
21
|
+
|
|
22
|
+
app.include_router(files_router, prefix="/api")
|
|
23
|
+
app.include_router(search_router, prefix="/api")
|
|
24
|
+
app.include_router(ai_router, prefix="/api")
|
|
25
|
+
|
|
26
|
+
# Serve static frontend files
|
|
27
|
+
static_dir = Path(__file__).parent / "static"
|
|
28
|
+
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
|
|
29
|
+
|
|
30
|
+
return app
|
|
File without changes
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""AI text refinement service using OpenAI."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
PRESETS: dict[str, str] = {
|
|
7
|
+
"concise": "Make this text more concise while preserving the meaning.",
|
|
8
|
+
"fix": "Fix grammar, spelling, and punctuation errors. Keep the original tone.",
|
|
9
|
+
"translate_en": "Translate this text to English. Preserve formatting.",
|
|
10
|
+
"translate_ko": "Translate this text to Korean. Preserve formatting.",
|
|
11
|
+
"formal": "Rewrite this text in a more formal, professional tone.",
|
|
12
|
+
"casual": "Rewrite this text in a more casual, friendly tone.",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
MODEL = "gpt-5-mini"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def refine_text(
|
|
19
|
+
text: str,
|
|
20
|
+
instruction: str,
|
|
21
|
+
preset: str | None = None,
|
|
22
|
+
) -> dict[str, Any]:
|
|
23
|
+
"""Refine text using OpenAI gpt-5-mini."""
|
|
24
|
+
from openai import AsyncOpenAI
|
|
25
|
+
|
|
26
|
+
api_key = os.environ.get("OPENAI_API_KEY")
|
|
27
|
+
if not api_key:
|
|
28
|
+
raise ValueError("No API key configured. Set OPENAI_API_KEY environment variable.")
|
|
29
|
+
|
|
30
|
+
if preset and preset in PRESETS:
|
|
31
|
+
instruction = PRESETS[preset]
|
|
32
|
+
|
|
33
|
+
if not instruction:
|
|
34
|
+
instruction = "Improve this text."
|
|
35
|
+
|
|
36
|
+
client = AsyncOpenAI(api_key=api_key)
|
|
37
|
+
response = await client.chat.completions.create(
|
|
38
|
+
model=MODEL,
|
|
39
|
+
messages=[
|
|
40
|
+
{
|
|
41
|
+
"role": "system",
|
|
42
|
+
"content": (
|
|
43
|
+
"You are a writing assistant. "
|
|
44
|
+
"The user will give you text and an instruction. "
|
|
45
|
+
"Return ONLY the refined text, nothing else. "
|
|
46
|
+
"Do not add explanations, preamble, or formatting wrappers."
|
|
47
|
+
),
|
|
48
|
+
},
|
|
49
|
+
{"role": "user", "content": f"Instruction: {instruction}\n\nText:\n{text}"},
|
|
50
|
+
],
|
|
51
|
+
)
|
|
52
|
+
return {
|
|
53
|
+
"refined": response.choices[0].message.content,
|
|
54
|
+
"model": MODEL,
|
|
55
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Filesystem service for markdown file operations."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import HTTPException
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _validate_path(root_dir: Path, relative_path: str) -> Path:
|
|
10
|
+
"""Validate and resolve a path, preventing path traversal attacks."""
|
|
11
|
+
resolved = (root_dir / relative_path).resolve()
|
|
12
|
+
if not str(resolved).startswith(str(root_dir)):
|
|
13
|
+
raise HTTPException(status_code=403, detail="Access denied: path traversal detected")
|
|
14
|
+
return resolved
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_tree(root_dir: Path) -> list[dict[str, Any]]:
|
|
18
|
+
"""Get a recursive tree of markdown files under root_dir."""
|
|
19
|
+
if not root_dir.is_dir():
|
|
20
|
+
return []
|
|
21
|
+
|
|
22
|
+
def _build_tree(directory: Path) -> list[dict[str, Any]]:
|
|
23
|
+
entries: list[dict[str, Any]] = []
|
|
24
|
+
try:
|
|
25
|
+
items = sorted(directory.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
|
26
|
+
except PermissionError:
|
|
27
|
+
return entries
|
|
28
|
+
|
|
29
|
+
for item in items:
|
|
30
|
+
if item.name.startswith("."):
|
|
31
|
+
continue
|
|
32
|
+
if item.is_dir():
|
|
33
|
+
children = _build_tree(item)
|
|
34
|
+
if children:
|
|
35
|
+
entries.append({
|
|
36
|
+
"name": item.name,
|
|
37
|
+
"path": str(item.relative_to(root_dir)),
|
|
38
|
+
"type": "directory",
|
|
39
|
+
"children": children,
|
|
40
|
+
})
|
|
41
|
+
elif item.suffix.lower() == ".md":
|
|
42
|
+
entries.append({
|
|
43
|
+
"name": item.name,
|
|
44
|
+
"path": str(item.relative_to(root_dir)),
|
|
45
|
+
"type": "file",
|
|
46
|
+
})
|
|
47
|
+
return entries
|
|
48
|
+
|
|
49
|
+
return _build_tree(root_dir)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_file(root_dir: Path, relative_path: str) -> str:
|
|
53
|
+
"""Read a markdown file and return its content."""
|
|
54
|
+
file_path = _validate_path(root_dir, relative_path)
|
|
55
|
+
if not file_path.is_file():
|
|
56
|
+
raise HTTPException(status_code=404, detail=f"File not found: {relative_path}")
|
|
57
|
+
return file_path.read_text(encoding="utf-8")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def write_file(root_dir: Path, relative_path: str, content: str) -> None:
|
|
61
|
+
"""Write content to a markdown file."""
|
|
62
|
+
file_path = _validate_path(root_dir, relative_path)
|
|
63
|
+
if not file_path.is_file():
|
|
64
|
+
raise HTTPException(status_code=404, detail=f"File not found: {relative_path}")
|
|
65
|
+
file_path.write_text(content, encoding="utf-8")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def create_file(root_dir: Path, relative_path: str, content: str = "") -> None:
|
|
69
|
+
"""Create a new markdown file."""
|
|
70
|
+
if not relative_path.endswith(".md"):
|
|
71
|
+
relative_path += ".md"
|
|
72
|
+
file_path = _validate_path(root_dir, relative_path)
|
|
73
|
+
if file_path.exists():
|
|
74
|
+
raise HTTPException(status_code=409, detail=f"File already exists: {relative_path}")
|
|
75
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
file_path.write_text(content, encoding="utf-8")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def delete_file(root_dir: Path, relative_path: str) -> None:
|
|
80
|
+
"""Delete a markdown file."""
|
|
81
|
+
file_path = _validate_path(root_dir, relative_path)
|
|
82
|
+
if not file_path.is_file():
|
|
83
|
+
raise HTTPException(status_code=404, detail=f"File not found: {relative_path}")
|
|
84
|
+
file_path.unlink()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Search service for markdown files."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def search_files(root_dir: Path, query: str, max_results: int = 50) -> list[dict[str, Any]]:
|
|
8
|
+
"""Search markdown files by filename and content."""
|
|
9
|
+
if not query.strip():
|
|
10
|
+
return []
|
|
11
|
+
|
|
12
|
+
query_lower = query.lower()
|
|
13
|
+
results: list[dict[str, Any]] = []
|
|
14
|
+
|
|
15
|
+
if not root_dir.is_dir():
|
|
16
|
+
return results
|
|
17
|
+
|
|
18
|
+
for md_file in sorted(root_dir.rglob("*.md")):
|
|
19
|
+
if md_file.name.startswith("."):
|
|
20
|
+
continue
|
|
21
|
+
# Skip hidden directories
|
|
22
|
+
if any(part.startswith(".") for part in md_file.relative_to(root_dir).parts):
|
|
23
|
+
continue
|
|
24
|
+
|
|
25
|
+
rel_path = str(md_file.relative_to(root_dir))
|
|
26
|
+
name_match = query_lower in md_file.name.lower()
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
content = md_file.read_text(encoding="utf-8")
|
|
30
|
+
except (PermissionError, UnicodeDecodeError):
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
content_lower = content.lower()
|
|
34
|
+
content_match = query_lower in content_lower
|
|
35
|
+
|
|
36
|
+
if name_match or content_match:
|
|
37
|
+
context = ""
|
|
38
|
+
if content_match:
|
|
39
|
+
idx = content_lower.index(query_lower)
|
|
40
|
+
start = max(0, idx - 40)
|
|
41
|
+
end = min(len(content), idx + len(query) + 40)
|
|
42
|
+
context = content[start:end].replace("\n", " ").strip()
|
|
43
|
+
if start > 0:
|
|
44
|
+
context = "..." + context
|
|
45
|
+
if end < len(content):
|
|
46
|
+
context = context + "..."
|
|
47
|
+
|
|
48
|
+
results.append({
|
|
49
|
+
"path": rel_path,
|
|
50
|
+
"name": md_file.name,
|
|
51
|
+
"name_match": name_match,
|
|
52
|
+
"content_match": content_match,
|
|
53
|
+
"context": context,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
if len(results) >= max_results:
|
|
57
|
+
break
|
|
58
|
+
|
|
59
|
+
# Sort: name matches first, then content matches
|
|
60
|
+
results.sort(key=lambda r: (not r["name_match"], r["path"]))
|
|
61
|
+
return results
|