mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-26 23:51:14 +02:00
Merge pull request #1617 from CREDO23/feature-okf
[Feat] Serve the knowledge base in Open Knowledge Format (OKF)
This commit is contained in:
commit
08d431454f
17 changed files with 1249 additions and 24 deletions
|
|
@ -1,7 +1,8 @@
|
|||
# Force asyncio to use standard event loop before unstructured imports
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Query, UploadFile
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from pydantic import BaseModel as PydanticBaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
|
@ -34,6 +35,8 @@ from app.schemas import (
|
|||
FolderRead,
|
||||
PaginatedResponse,
|
||||
)
|
||||
from app.services.export_service import resolve_document_markdown
|
||||
from app.services.okf import document_to_concept
|
||||
from app.services.task_dispatcher import TaskDispatcher, get_task_dispatcher
|
||||
from app.users import get_auth_context
|
||||
from app.utils.rbac import check_permission
|
||||
|
|
@ -1255,12 +1258,14 @@ async def get_document_chunks_paginated(
|
|||
@router.get("/documents/{document_id}", response_model=DocumentRead)
|
||||
async def read_document(
|
||||
document_id: int,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
):
|
||||
"""
|
||||
Get a specific document by ID.
|
||||
Requires DOCUMENTS_READ permission for the workspace.
|
||||
"""Get a document by ID.
|
||||
|
||||
``Accept: text/markdown`` returns the OKF concept; otherwise the JSON record.
|
||||
Requires DOCUMENTS_READ permission.
|
||||
"""
|
||||
try:
|
||||
result = await session.execute(
|
||||
|
|
@ -1282,6 +1287,16 @@ async def read_document(
|
|||
"You don't have permission to read documents in this workspace",
|
||||
)
|
||||
|
||||
# ponytail: substring match, not RFC 7231 q-values (OKF is the only non-JSON view).
|
||||
if "text/markdown" in request.headers.get("accept", ""):
|
||||
body = (
|
||||
await resolve_document_markdown(session, document)
|
||||
or document.content
|
||||
or ""
|
||||
)
|
||||
concept = document_to_concept(document, body=body)
|
||||
return PlainTextResponse(concept, media_type="text/markdown")
|
||||
|
||||
raw_content = document.content or ""
|
||||
return DocumentRead(
|
||||
id=document.id,
|
||||
|
|
|
|||
|
|
@ -12,9 +12,26 @@ from sqlalchemy.future import select
|
|||
|
||||
from app.db import Chunk, Document, Folder
|
||||
from app.services.folder_service import get_folder_subtree_ids
|
||||
from app.services.okf import (
|
||||
INDEX_FILENAME,
|
||||
LOG_FILENAME,
|
||||
ConceptRef,
|
||||
LogEntry,
|
||||
SubdirRef,
|
||||
document_to_concept,
|
||||
folder_to_index,
|
||||
folder_to_log,
|
||||
okf_type,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Root index.md declares the targeted OKF version in frontmatter - the one place
|
||||
# the spec permits frontmatter in an index file.
|
||||
_ROOT_INDEX_FRONTMATTER = '---\nokf_version: "0.1"\n---\n\n'
|
||||
|
||||
_RESERVED_STEMS = {"index", "log"}
|
||||
|
||||
|
||||
def _sanitize_filename(title: str) -> str:
|
||||
safe = "".join(c if c.isalnum() or c in " -_." else "_" for c in title).strip()
|
||||
|
|
@ -43,7 +60,7 @@ def _build_folder_path_map(folders: list[Folder]) -> dict[int, str]:
|
|||
return cache
|
||||
|
||||
|
||||
async def _get_document_markdown(
|
||||
async def resolve_document_markdown(
|
||||
session: AsyncSession, document: Document
|
||||
) -> str | None:
|
||||
"""Resolve markdown content using the 3-tier fallback:
|
||||
|
|
@ -71,6 +88,67 @@ async def _get_document_markdown(
|
|||
return None
|
||||
|
||||
|
||||
def _build_index_files(
|
||||
dir_concepts: dict[str, list[ConceptRef]],
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Build ``index.md`` files for every directory (and ancestor) with content.
|
||||
|
||||
Produces OKF progressive-disclosure listings: each directory lists its
|
||||
concepts (grouped by type) and its immediate subdirectories. The bundle-root
|
||||
index also declares ``okf_version``.
|
||||
"""
|
||||
all_dirs: set[str] = {""}
|
||||
for dir_path in dir_concepts:
|
||||
all_dirs.add(dir_path)
|
||||
parts = dir_path.split("/") if dir_path else []
|
||||
for i in range(1, len(parts)):
|
||||
all_dirs.add("/".join(parts[:i]))
|
||||
|
||||
children_by_dir: dict[str, list[str]] = {}
|
||||
for dir_path in all_dirs:
|
||||
if not dir_path:
|
||||
continue
|
||||
parent = dir_path.rsplit("/", 1)[0] if "/" in dir_path else ""
|
||||
children_by_dir.setdefault(parent, []).append(dir_path)
|
||||
|
||||
index_files: list[tuple[str, str]] = []
|
||||
for dir_path in all_dirs:
|
||||
subdirs = [
|
||||
SubdirRef(name=child.rsplit("/", 1)[-1])
|
||||
for child in children_by_dir.get(dir_path, [])
|
||||
]
|
||||
body = folder_to_index(
|
||||
concepts=dir_concepts.get(dir_path, []),
|
||||
subdirectories=subdirs,
|
||||
)
|
||||
if not body:
|
||||
continue
|
||||
if dir_path:
|
||||
index_files.append((f"{dir_path}/{INDEX_FILENAME}", body))
|
||||
else:
|
||||
index_files.append((INDEX_FILENAME, _ROOT_INDEX_FRONTMATTER + body))
|
||||
|
||||
return index_files
|
||||
|
||||
|
||||
def _build_log_files(
|
||||
dir_logs: dict[str, list[LogEntry]],
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Build ``log.md`` files for every directory that holds concepts.
|
||||
|
||||
Unlike ``index.md``, logs are only synthesized where documents actually live
|
||||
(no ancestor synthesis): an empty intermediate directory has nothing to log.
|
||||
"""
|
||||
log_files: list[tuple[str, str]] = []
|
||||
for dir_path, entries in dir_logs.items():
|
||||
body = folder_to_log(entries)
|
||||
if not body:
|
||||
continue
|
||||
path = f"{dir_path}/{LOG_FILENAME}" if dir_path else LOG_FILENAME
|
||||
log_files.append((path, body))
|
||||
return log_files
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExportResult:
|
||||
zip_path: str
|
||||
|
|
@ -120,6 +198,10 @@ async def build_export_zip(
|
|||
used_paths: dict[str, int] = {}
|
||||
skipped_docs: list[str] = []
|
||||
is_first_batch = True
|
||||
# dir path -> concepts it holds, accumulated across batches for index.md.
|
||||
dir_concepts: dict[str, list[ConceptRef]] = {}
|
||||
# dir path -> log entries it holds, accumulated across batches for log.md.
|
||||
dir_logs: dict[str, list[LogEntry]] = {}
|
||||
|
||||
try:
|
||||
offset = 0
|
||||
|
|
@ -143,7 +225,7 @@ async def build_export_zip(
|
|||
skipped_docs.append(doc.title or "Untitled")
|
||||
continue
|
||||
|
||||
markdown = await _get_document_markdown(session, doc)
|
||||
markdown = await resolve_document_markdown(session, doc)
|
||||
if not markdown or not markdown.strip():
|
||||
continue
|
||||
|
||||
|
|
@ -153,6 +235,9 @@ async def build_export_zip(
|
|||
dir_path = ""
|
||||
|
||||
base_name = _sanitize_filename(doc.title or "Untitled")
|
||||
# Never collide with reserved OKF filenames (index.md, log.md).
|
||||
if base_name.lower() in _RESERVED_STEMS:
|
||||
base_name = f"{base_name}_"
|
||||
file_path = (
|
||||
f"{dir_path}/{base_name}.md" if dir_path else f"{base_name}.md"
|
||||
)
|
||||
|
|
@ -160,14 +245,41 @@ async def build_export_zip(
|
|||
if file_path in used_paths:
|
||||
used_paths[file_path] += 1
|
||||
suffix = used_paths[file_path]
|
||||
base_name = f"{base_name}_{suffix}"
|
||||
file_path = (
|
||||
f"{dir_path}/{base_name}_{suffix}.md"
|
||||
f"{dir_path}/{base_name}.md"
|
||||
if dir_path
|
||||
else f"{base_name}_{suffix}.md"
|
||||
else f"{base_name}.md"
|
||||
)
|
||||
used_paths[file_path] = used_paths.get(file_path, 0) + 1
|
||||
|
||||
entries.append((file_path, markdown))
|
||||
concept = document_to_concept(doc, body=markdown)
|
||||
entries.append((file_path, concept))
|
||||
|
||||
metadata = (
|
||||
doc.document_metadata
|
||||
if isinstance(doc.document_metadata, dict)
|
||||
else {}
|
||||
)
|
||||
description = metadata.get("description")
|
||||
dir_concepts.setdefault(dir_path, []).append(
|
||||
ConceptRef(
|
||||
title=doc.title or "Untitled",
|
||||
filename=f"{base_name}.md",
|
||||
type=okf_type(doc.document_type),
|
||||
description=description
|
||||
if isinstance(description, str) and description.strip()
|
||||
else None,
|
||||
)
|
||||
)
|
||||
|
||||
changed_at = doc.updated_at or doc.created_at
|
||||
dir_logs.setdefault(dir_path, []).append(
|
||||
LogEntry(
|
||||
title=doc.title or "Untitled",
|
||||
timestamp=changed_at.isoformat() if changed_at else None,
|
||||
)
|
||||
)
|
||||
|
||||
if entries:
|
||||
mode = "w" if is_first_batch else "a"
|
||||
|
|
@ -183,6 +295,30 @@ async def build_export_zip(
|
|||
|
||||
offset += batch_size
|
||||
|
||||
index_files = _build_index_files(dir_concepts)
|
||||
if index_files:
|
||||
mode = "w" if is_first_batch else "a"
|
||||
|
||||
def _write_indexes(m: str = mode, e: list = index_files) -> None:
|
||||
with zipfile.ZipFile(tmp_path, m, zipfile.ZIP_DEFLATED) as zf:
|
||||
for path, content in e:
|
||||
zf.writestr(path, content)
|
||||
|
||||
await asyncio.to_thread(_write_indexes)
|
||||
is_first_batch = False
|
||||
|
||||
log_files = _build_log_files(dir_logs)
|
||||
if log_files:
|
||||
mode = "w" if is_first_batch else "a"
|
||||
|
||||
def _write_logs(m: str = mode, e: list = log_files) -> None:
|
||||
with zipfile.ZipFile(tmp_path, m, zipfile.ZIP_DEFLATED) as zf:
|
||||
for path, content in e:
|
||||
zf.writestr(path, content)
|
||||
|
||||
await asyncio.to_thread(_write_logs)
|
||||
is_first_batch = False
|
||||
|
||||
export_name = "knowledge-base"
|
||||
if folder_id is not None and folder_id in folder_path_map:
|
||||
export_name = _sanitize_filename(folder_path_map[folder_id].split("/")[0])
|
||||
|
|
|
|||
56
surfsense_backend/app/services/okf/__init__.py
Normal file
56
surfsense_backend/app/services/okf/__init__.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Open Knowledge Format (OKF v0.1) serialization for the SurfSense KB.
|
||||
|
||||
Single source of truth for turning documents and folders into OKF concepts,
|
||||
``index.md`` listings and ``log.md`` logs. Pure KB-layer functions; every
|
||||
consumer (ZIP export, REST, MCP, agents) calls in.
|
||||
|
||||
The OKF-native model: ``Document`` rows are canonical, and frontmatter is
|
||||
*derived* from their columns on read (never stored), so rows are conformant by
|
||||
construction. Chunks and embeddings are a *derived*, rebuildable search
|
||||
projection - never a source of truth.
|
||||
|
||||
Spec: https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md
|
||||
"""
|
||||
|
||||
from app.services.okf.serializer import (
|
||||
INDEX_FILENAME,
|
||||
LOG_FILENAME,
|
||||
ConceptRef,
|
||||
LogEntry,
|
||||
SubdirRef,
|
||||
build_frontmatter,
|
||||
document_to_concept,
|
||||
folder_to_index,
|
||||
folder_to_log,
|
||||
render_frontmatter,
|
||||
)
|
||||
from app.services.okf.type_mapping import okf_resource, okf_type
|
||||
from app.services.okf.validator import (
|
||||
RECOMMENDED_FRONTMATTER_KEYS,
|
||||
REQUIRED_FRONTMATTER_KEYS,
|
||||
is_conformant_concept,
|
||||
parse_frontmatter,
|
||||
validate_bundle,
|
||||
validate_concept,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"INDEX_FILENAME",
|
||||
"LOG_FILENAME",
|
||||
"ConceptRef",
|
||||
"LogEntry",
|
||||
"SubdirRef",
|
||||
"build_frontmatter",
|
||||
"document_to_concept",
|
||||
"folder_to_index",
|
||||
"folder_to_log",
|
||||
"render_frontmatter",
|
||||
"okf_resource",
|
||||
"okf_type",
|
||||
"RECOMMENDED_FRONTMATTER_KEYS",
|
||||
"REQUIRED_FRONTMATTER_KEYS",
|
||||
"is_conformant_concept",
|
||||
"parse_frontmatter",
|
||||
"validate_bundle",
|
||||
"validate_concept",
|
||||
]
|
||||
193
surfsense_backend/app/services/okf/serializer.py
Normal file
193
surfsense_backend/app/services/okf/serializer.py
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
"""Serialize SurfSense knowledge into Open Knowledge Format (OKF v0.1).
|
||||
|
||||
Pure functions with no HTTP / MCP / framework dependencies: given a
|
||||
:class:`~app.db.Document` (and, for listings, its neighbours) they return
|
||||
OKF-conformant markdown. Every consumer (ZIP export, REST, MCP, agents) calls
|
||||
these rather than re-implementing frontmatter.
|
||||
|
||||
Spec: https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from app.db import Document
|
||||
from app.services.okf.type_mapping import okf_resource, okf_type
|
||||
|
||||
# Reserved OKF filenames; never used for concept documents.
|
||||
INDEX_FILENAME = "index.md"
|
||||
LOG_FILENAME = "log.md"
|
||||
|
||||
_FRONTMATTER_DELIMITER = "---"
|
||||
|
||||
|
||||
def _tags_from_metadata(metadata: dict[str, Any] | None) -> list[str] | None:
|
||||
if not isinstance(metadata, dict):
|
||||
return None
|
||||
raw = metadata.get("tags")
|
||||
if isinstance(raw, list):
|
||||
tags = [str(t).strip() for t in raw if str(t).strip()]
|
||||
return tags or None
|
||||
return None
|
||||
|
||||
|
||||
def _timestamp(document: Document) -> str | None:
|
||||
when = document.updated_at or document.created_at
|
||||
if when is None:
|
||||
return None
|
||||
# ISO 8601, matching Google's sample bundles (e.g. 2026-05-28T22:49:59+00:00).
|
||||
return when.isoformat()
|
||||
|
||||
|
||||
def build_frontmatter(document: Document) -> dict[str, Any]:
|
||||
"""Build the ordered OKF frontmatter mapping for a document.
|
||||
|
||||
Only ``type`` is required; recommended keys are included only when we have a
|
||||
value. Insertion order is preserved in the emitted YAML.
|
||||
"""
|
||||
metadata = document.document_metadata if isinstance(document.document_metadata, dict) else {}
|
||||
|
||||
frontmatter: dict[str, Any] = {"type": okf_type(document.document_type)}
|
||||
|
||||
resource = okf_resource(document.document_type, metadata)
|
||||
if resource:
|
||||
frontmatter["resource"] = resource
|
||||
|
||||
title = (document.title or "").strip()
|
||||
if title:
|
||||
frontmatter["title"] = title
|
||||
|
||||
description = metadata.get("description")
|
||||
if isinstance(description, str) and description.strip():
|
||||
frontmatter["description"] = description.strip()
|
||||
|
||||
tags = _tags_from_metadata(metadata)
|
||||
if tags:
|
||||
frontmatter["tags"] = tags
|
||||
|
||||
timestamp = _timestamp(document)
|
||||
if timestamp:
|
||||
frontmatter["timestamp"] = timestamp
|
||||
|
||||
return frontmatter
|
||||
|
||||
|
||||
def render_frontmatter(frontmatter: dict[str, Any]) -> str:
|
||||
"""Render a frontmatter mapping as a YAML block delimited by ``---``."""
|
||||
body = yaml.safe_dump(
|
||||
frontmatter,
|
||||
sort_keys=False,
|
||||
allow_unicode=True,
|
||||
default_flow_style=False,
|
||||
)
|
||||
return f"{_FRONTMATTER_DELIMITER}\n{body}{_FRONTMATTER_DELIMITER}\n"
|
||||
|
||||
|
||||
def document_to_concept(document: Document, *, body: str) -> str:
|
||||
"""Serialize a document as an OKF concept: frontmatter + ``body``.
|
||||
|
||||
``body`` is caller-resolved markdown, keeping this a pure formatting step.
|
||||
"""
|
||||
frontmatter = render_frontmatter(build_frontmatter(document))
|
||||
body_text = (body or "").strip("\n")
|
||||
return f"{frontmatter}\n{body_text}\n"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConceptRef:
|
||||
"""One concept entry for an ``index.md`` listing."""
|
||||
|
||||
title: str
|
||||
filename: str # relative to the directory, e.g. "orders.md"
|
||||
type: str # OKF type, used as the grouping heading
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubdirRef:
|
||||
"""One subdirectory entry for an ``index.md`` listing."""
|
||||
|
||||
name: str # directory name, e.g. "tables"
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LogEntry:
|
||||
"""One line of an OKF ``log.md``: a concept and when it last changed."""
|
||||
|
||||
title: str
|
||||
timestamp: str | None = None # ISO-8601, or None when unknown
|
||||
|
||||
|
||||
def folder_to_log(entries: list[LogEntry]) -> str:
|
||||
"""Build a minimal OKF ``log.md`` body for one directory.
|
||||
|
||||
Lists each concept newest-first with the time it last changed (from a
|
||||
document's ``updated_at``/``created_at``); undated entries sort last. Returns
|
||||
an empty string when there is nothing to log.
|
||||
|
||||
ponytail: this is a last-touched summary, not a per-field change history. A
|
||||
fuller changelog would read ``DocumentVersion`` rows; upgrade there if
|
||||
consumers ever need diffs rather than "what changed and when".
|
||||
"""
|
||||
if not entries:
|
||||
return ""
|
||||
ordered = sorted(
|
||||
entries,
|
||||
key=lambda e: (e.timestamp is not None, e.timestamp or "", e.title),
|
||||
reverse=True,
|
||||
)
|
||||
lines = ["# Change Log", ""]
|
||||
for entry in ordered:
|
||||
when = f" - {entry.timestamp}" if entry.timestamp else ""
|
||||
lines.append(f"* {entry.title}{when}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _index_bullet(title: str, link: str, description: str | None) -> str:
|
||||
bullet = f"* [{title}]({link})"
|
||||
if description:
|
||||
# Keep index descriptions to a single line.
|
||||
bullet += f" - {' '.join(description.split())}"
|
||||
return bullet
|
||||
|
||||
|
||||
def folder_to_index(
|
||||
*,
|
||||
concepts: list[ConceptRef] | None = None,
|
||||
subdirectories: list[SubdirRef] | None = None,
|
||||
) -> str:
|
||||
"""Build an OKF ``index.md`` body (no frontmatter) for one directory.
|
||||
|
||||
Subdirectories are listed under a ``# Subdirectories`` heading and concepts
|
||||
are grouped under their ``type`` heading, mirroring Google's sample bundles.
|
||||
Returns an empty string when there is nothing to list.
|
||||
"""
|
||||
concepts = concepts or []
|
||||
subdirectories = subdirectories or []
|
||||
sections: list[str] = []
|
||||
|
||||
if subdirectories:
|
||||
lines = ["# Subdirectories", ""]
|
||||
for sub in sorted(subdirectories, key=lambda s: s.name.lower()):
|
||||
lines.append(
|
||||
_index_bullet(sub.name, f"{sub.name}/{INDEX_FILENAME}", sub.description)
|
||||
)
|
||||
sections.append("\n".join(lines))
|
||||
|
||||
by_type: dict[str, list[ConceptRef]] = {}
|
||||
for concept in concepts:
|
||||
by_type.setdefault(concept.type, []).append(concept)
|
||||
for type_heading in sorted(by_type):
|
||||
lines = [f"# {type_heading}", ""]
|
||||
for concept in sorted(by_type[type_heading], key=lambda c: c.title.lower()):
|
||||
lines.append(
|
||||
_index_bullet(concept.title, concept.filename, concept.description)
|
||||
)
|
||||
sections.append("\n".join(lines))
|
||||
|
||||
return ("\n\n".join(sections) + "\n") if sections else ""
|
||||
124
surfsense_backend/app/services/okf/type_mapping.py
Normal file
124
surfsense_backend/app/services/okf/type_mapping.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""Map SurfSense document types to OKF concept ``type`` strings and ``resource`` URIs.
|
||||
|
||||
OKF (Open Knowledge Format v0.1) requires a non-empty, human-friendly ``type`` on
|
||||
every concept and recommends a ``resource`` URI pointing at the underlying asset.
|
||||
SurfSense stores the type as a :class:`~app.db.DocumentType` enum and the source
|
||||
URL (when one exists) inside the free-form ``document_metadata`` JSON, under keys
|
||||
that differ per connector. This module is the single place that knows both mappings.
|
||||
|
||||
Spec: https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.db import DocumentType
|
||||
|
||||
# Title-cased type strings per Google's sample bundles. Types absent here fall
|
||||
# back to a derived string (see ``okf_type``), so this need not be exhaustive.
|
||||
OKF_TYPE_BY_DOCUMENT_TYPE: dict[DocumentType, str] = {
|
||||
DocumentType.EXTENSION: "Web Page",
|
||||
DocumentType.CRAWLED_URL: "Web Page",
|
||||
DocumentType.FILE: "File",
|
||||
DocumentType.NOTE: "Note",
|
||||
DocumentType.SLACK_CONNECTOR: "Slack Message",
|
||||
DocumentType.TEAMS_CONNECTOR: "Teams Message",
|
||||
DocumentType.ONEDRIVE_FILE: "OneDrive File",
|
||||
DocumentType.NOTION_CONNECTOR: "Notion Page",
|
||||
DocumentType.YOUTUBE_VIDEO: "YouTube Video",
|
||||
DocumentType.GITHUB_CONNECTOR: "GitHub Document",
|
||||
DocumentType.LINEAR_CONNECTOR: "Linear Issue",
|
||||
DocumentType.DISCORD_CONNECTOR: "Discord Message",
|
||||
DocumentType.JIRA_CONNECTOR: "Jira Issue",
|
||||
DocumentType.CONFLUENCE_CONNECTOR: "Confluence Page",
|
||||
DocumentType.CLICKUP_CONNECTOR: "ClickUp Task",
|
||||
DocumentType.GOOGLE_CALENDAR_CONNECTOR: "Google Calendar Event",
|
||||
DocumentType.GOOGLE_GMAIL_CONNECTOR: "Gmail Message",
|
||||
DocumentType.GOOGLE_DRIVE_FILE: "Google Drive File",
|
||||
DocumentType.AIRTABLE_CONNECTOR: "Airtable Record",
|
||||
DocumentType.LUMA_CONNECTOR: "Luma Event",
|
||||
DocumentType.ELASTICSEARCH_CONNECTOR: "Elasticsearch Document",
|
||||
DocumentType.BOOKSTACK_CONNECTOR: "BookStack Page",
|
||||
DocumentType.CIRCLEBACK: "Circleback Meeting",
|
||||
DocumentType.OBSIDIAN_CONNECTOR: "Obsidian Note",
|
||||
DocumentType.DROPBOX_FILE: "Dropbox File",
|
||||
DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Google Drive File",
|
||||
DocumentType.COMPOSIO_GMAIL_CONNECTOR: "Gmail Message",
|
||||
DocumentType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Google Calendar Event",
|
||||
DocumentType.LOCAL_FOLDER_FILE: "File",
|
||||
}
|
||||
|
||||
|
||||
def _coerce_document_type(document_type: DocumentType | str | None) -> DocumentType | None:
|
||||
if document_type is None:
|
||||
return None
|
||||
try:
|
||||
return DocumentType(document_type)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def okf_type(document_type: DocumentType | str | None) -> str:
|
||||
"""Return the OKF ``type`` string for a SurfSense document type.
|
||||
|
||||
Falls back to a Title-Cased version of the enum value so the required
|
||||
``type`` field is never empty, even for types added after this map.
|
||||
"""
|
||||
dt = _coerce_document_type(document_type)
|
||||
if dt is None:
|
||||
if document_type is None:
|
||||
return "Document"
|
||||
return str(document_type).replace("_", " ").title() or "Document"
|
||||
return OKF_TYPE_BY_DOCUMENT_TYPE.get(dt) or dt.value.replace("_", " ").title()
|
||||
|
||||
|
||||
# Per-type metadata keys that hold the canonical source URL, checked first.
|
||||
_RESOURCE_KEYS_BY_TYPE: dict[DocumentType, tuple[str, ...]] = {
|
||||
DocumentType.GOOGLE_DRIVE_FILE: ("webViewLink", "web_view_link"),
|
||||
DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR: ("webViewLink", "web_view_link"),
|
||||
DocumentType.GITHUB_CONNECTOR: ("html_url", "url"),
|
||||
DocumentType.NOTION_CONNECTOR: ("url",),
|
||||
DocumentType.LINEAR_CONNECTOR: ("url",),
|
||||
DocumentType.JIRA_CONNECTOR: ("url",),
|
||||
DocumentType.CONFLUENCE_CONNECTOR: ("url",),
|
||||
DocumentType.SLACK_CONNECTOR: ("permalink", "url"),
|
||||
DocumentType.DISCORD_CONNECTOR: ("url",),
|
||||
DocumentType.CLICKUP_CONNECTOR: ("url",),
|
||||
DocumentType.YOUTUBE_VIDEO: ("url", "video_url"),
|
||||
DocumentType.LUMA_CONNECTOR: ("url",),
|
||||
DocumentType.BOOKSTACK_CONNECTOR: ("url",),
|
||||
}
|
||||
|
||||
# Generic URL-bearing keys checked for any type as a fallback.
|
||||
_GENERIC_RESOURCE_KEYS: tuple[str, ...] = (
|
||||
"url",
|
||||
"URL",
|
||||
"source_url",
|
||||
"sourceUrl",
|
||||
"source",
|
||||
"link",
|
||||
"permalink",
|
||||
"web_url",
|
||||
"webViewLink",
|
||||
"html_url",
|
||||
)
|
||||
|
||||
|
||||
def okf_resource(
|
||||
document_type: DocumentType | str | None, metadata: dict[str, Any] | None
|
||||
) -> str | None:
|
||||
"""Return the canonical source URI for a document, or ``None`` if it has none."""
|
||||
if not isinstance(metadata, dict) or not metadata:
|
||||
return None
|
||||
|
||||
dt = _coerce_document_type(document_type)
|
||||
type_keys = _RESOURCE_KEYS_BY_TYPE.get(dt, ()) if dt is not None else ()
|
||||
|
||||
for key in (*type_keys, *_GENERIC_RESOURCE_KEYS):
|
||||
value = metadata.get(key)
|
||||
if isinstance(value, str):
|
||||
candidate = value.strip()
|
||||
if candidate.startswith(("http://", "https://")):
|
||||
return candidate
|
||||
return None
|
||||
90
surfsense_backend/app/services/okf/validator.py
Normal file
90
surfsense_backend/app/services/okf/validator.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Minimal OKF v0.1 conformance checks.
|
||||
|
||||
A bundle conforms if every non-reserved ``.md`` file has a parseable YAML
|
||||
frontmatter block whose ``type`` is a non-empty string. Reserved files
|
||||
(``index.md``, ``log.md``) are exempt from the frontmatter requirement.
|
||||
Consumers must stay permissive, so this is for producers to self-check what they
|
||||
emit, not to reject foreign bundles.
|
||||
|
||||
Spec: https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import yaml
|
||||
|
||||
from app.services.okf.serializer import INDEX_FILENAME, LOG_FILENAME
|
||||
|
||||
_DELIMITER = "---"
|
||||
RESERVED_FILENAMES = frozenset({INDEX_FILENAME, LOG_FILENAME})
|
||||
|
||||
# The OKF v0.1 frontmatter contract, shared by serializer and validator. Only
|
||||
# ``type`` is mandatory; the rest are recommended.
|
||||
REQUIRED_FRONTMATTER_KEYS: tuple[str, ...] = ("type",)
|
||||
RECOMMENDED_FRONTMATTER_KEYS: tuple[str, ...] = (
|
||||
"title",
|
||||
"description",
|
||||
"resource",
|
||||
"tags",
|
||||
"timestamp",
|
||||
)
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> tuple[dict | None, str | None]:
|
||||
"""Split an OKF concept into ``(frontmatter_dict, error)``.
|
||||
|
||||
Returns ``(dict, None)`` on success or ``(None, reason)`` if the frontmatter
|
||||
block is missing or not a parseable YAML mapping.
|
||||
"""
|
||||
if not text.startswith(_DELIMITER + "\n"):
|
||||
return None, "missing opening '---' frontmatter delimiter"
|
||||
rest = text[len(_DELIMITER) + 1 :]
|
||||
end = rest.find("\n" + _DELIMITER)
|
||||
if end == -1:
|
||||
return None, "missing closing '---' frontmatter delimiter"
|
||||
block = rest[:end]
|
||||
try:
|
||||
data = yaml.safe_load(block)
|
||||
except yaml.YAMLError as exc:
|
||||
return None, f"frontmatter is not valid YAML: {exc}"
|
||||
if not isinstance(data, dict):
|
||||
return None, "frontmatter is not a YAML mapping"
|
||||
return data, None
|
||||
|
||||
|
||||
def validate_concept(text: str) -> list[str]:
|
||||
"""Return conformance errors for a single concept document (empty == conforms)."""
|
||||
frontmatter, error = parse_frontmatter(text)
|
||||
if error:
|
||||
return [error]
|
||||
errors: list[str] = []
|
||||
for key in REQUIRED_FRONTMATTER_KEYS:
|
||||
value = frontmatter.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
errors.append(f"frontmatter '{key}' is missing or empty")
|
||||
return errors
|
||||
|
||||
|
||||
def is_conformant_concept(text: str) -> bool:
|
||||
"""True if a single concept document conforms to OKF v0.1."""
|
||||
return not validate_concept(text)
|
||||
|
||||
|
||||
def validate_bundle(files: dict[str, str]) -> dict[str, list[str]]:
|
||||
"""Validate a bundle given a mapping of relative path -> file contents.
|
||||
|
||||
Only non-reserved ``.md`` files are checked for concept conformance. Returns
|
||||
a mapping of path -> errors for every non-conformant file (an empty mapping
|
||||
means the whole bundle conforms).
|
||||
"""
|
||||
problems: dict[str, list[str]] = {}
|
||||
for path, contents in files.items():
|
||||
if not path.endswith(".md"):
|
||||
continue
|
||||
filename = path.rsplit("/", 1)[-1]
|
||||
if filename in RESERVED_FILENAMES:
|
||||
continue
|
||||
errors = validate_concept(contents)
|
||||
if errors:
|
||||
problems[path] = errors
|
||||
return problems
|
||||
Loading…
Add table
Add a link
Reference in a new issue