Merge pull request #1617 from CREDO23/feature-okf

[Feat] Serve the knowledge base in Open Knowledge Format (OKF)
This commit is contained in:
Rohan Verma 2026-07-21 15:39:59 -07:00 committed by GitHub
commit 08d431454f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1249 additions and 24 deletions

View file

@ -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,

View file

@ -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])

View 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",
]

View 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 ""

View 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

View 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