Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 49 additions & 21 deletions robosystems/graphql/resolvers/information_block.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
get_information_block,
list_information_blocks,
)
from robosystems.operations.roboledger.entity_scope import EntityNotInGraphError


@strawberry.type
Expand All @@ -46,6 +47,7 @@ def information_block(
series: bool | None = None,
series_history: int | None = None,
series_forecast: int | None = None,
entity_id: str | None = None,
) -> InformationBlock | None:
"""Fetch a single Information Block envelope by id.

Expand All @@ -65,6 +67,12 @@ def information_block(
forecast columns; omitted = unbounded. Pass the visible window so
the envelope scales with the screen, not the ledger's age.

`entityId` picks whose books a block shared by the group reads
(statements, metrics, disclosures); omitted = the scenario's entity,
else the group parent. A schedule, reconciliation or forecast is one
entity's own and always reads its owner's. `entityId` on the envelope
names the entity it read.

Args:
id: The block's structure id.
scenario_id: A forecast block's structure id. Omit for actuals.
Expand All @@ -73,17 +81,25 @@ def information_block(
series_history: Cap the series at the last N actual columns. Omit for unbounded.
series_forecast: Cap the series at the first N forecast columns. Omit for
unbounded.
entity_id: The entity whose books a shared block reads. Omit for the group
parent.
"""
with _open_session(info) as session:
envelope = get_information_block(
session,
str(id),
scenario_id=scenario_id,
series=bool(series),
series_history=series_history,
series_forecast=series_forecast,
)
return InformationBlock.from_pydantic(envelope) if envelope else None
graph_id = require_graph_id(info)
try:
with _open_session(info) as session:
envelope = get_information_block(
session,
str(id),
scenario_id=scenario_id,
series=bool(series),
series_history=series_history,
series_forecast=series_forecast,
entity_id=entity_id,
library_sentinel=(graph_id == LIBRARY_GRAPH_ID),
)
except EntityNotInGraphError as exc:
raise strawberry.exceptions.StrawberryGraphQLError(str(exc)) from exc
return InformationBlock.from_pydantic(envelope) if envelope else None

@strawberry.field
def information_blocks(
Expand All @@ -94,6 +110,7 @@ def information_blocks(
limit: int | None = None,
offset: int | None = None,
scenario_id: str | None = None,
entity_id: str | None = None,
) -> list[InformationBlock]:
"""List Information Blocks with optional block_type + category filters.

Expand All @@ -103,26 +120,37 @@ def information_blocks(
`scenarioId` threads into each envelope's FactSet binding (the
Structure list itself is scenario-independent).

`entityId` (omitted = the scenario's entity, else the group parent)
lists the blocks shared by the group plus that entity's own schedules,
reconciliations and forecasts, never another entity's; shared blocks
read its books.

Args:
block_type: Filter to one registered block type, e.g. `schedule`.
category: Filter on the registry entry's category label, e.g. `Close` or
`Reporting`.
scenario_id: A forecast block's structure id, threaded into each envelope's
FactSet binding.
entity_id: The entity whose blocks and books to list. Omit for the group
parent.
"""
limit, offset = _resolve_pagination(limit, offset, default_limit=50)
graph_id = require_graph_id(info)
with _open_session(info) as session:
rows = list_information_blocks(
session,
block_type=block_type,
category=category,
limit=limit,
offset=offset,
library_sentinel=(graph_id == LIBRARY_GRAPH_ID),
scenario_id=scenario_id,
)
return [InformationBlock.from_pydantic(r) for r in rows]
try:
with _open_session(info) as session:
rows = list_information_blocks(
session,
block_type=block_type,
category=category,
limit=limit,
offset=offset,
library_sentinel=(graph_id == LIBRARY_GRAPH_ID),
scenario_id=scenario_id,
entity_id=entity_id,
)
except EntityNotInGraphError as exc:
raise strawberry.exceptions.StrawberryGraphQLError(str(exc)) from exc
return [InformationBlock.from_pydantic(r) for r in rows]


__all__ = [
Expand Down
45 changes: 32 additions & 13 deletions robosystems/graphql/resolvers/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@
from robosystems.models.api.extensions.reports import (
ReportLifecycle as PydanticReportLifecycle,
)
from robosystems.operations.roboledger.entity_scope import EntityNotInGraphError
from robosystems.operations.roboledger.entity_scope import (
EntityNotInGraphError,
NoEntityError,
resolve_entity_id,
)
from robosystems.operations.roboledger.fiscal_calendar import (
FiscalCalendarService,
parse_period,
Expand Down Expand Up @@ -135,7 +139,7 @@
trial_balance as reads_trial_balance,
)
from robosystems.operations.roboledger.reports.network_picker import (
load_primary_reporting_style,
load_entity_reporting_style,
)
from robosystems.operations.roboledger.schedules import ScheduleService
from robosystems.operations.taxonomy_block.chart_templates import (
Expand Down Expand Up @@ -885,32 +889,38 @@ def mapping_candidates(
self,
info: Info[GraphQLContext, None],
classification: str,
entity_id: str | None = None,
) -> list[Element]:
"""The rs-gaap concepts a chart-of-accounts element may map to.

Limited to concepts that actually render under the active Reporting Style
— falling back to the rs-gaap-presentation set when no Style is seeded —
with statement-level subtotals excluded. It is the same candidate set the
mapping operator picks from, so a target that would land a fact on an
unreachable branch is never offered.
Limited to concepts that actually render under the entity's Reporting
Style — falling back to the rs-gaap-presentation set when no Style is
seeded — with statement-level subtotals excluded. It is the same candidate
set the mapping operator picks from, so a target that would land a fact on
an unreachable branch is never offered.

Args:
classification: The CoA element's EFS classification - asset, liability, equity,
revenue, expense, gain or loss - whose eligible rs-gaap targets to return.
entity_id: The entity whose chart is being mapped. Omit for the group parent.
"""
# Narrow to what the entity's Reporting Style renders, so the picker matches
# the renderer; with no entity, the rs-gaap-presentation set is used.
try:
with _open_session(info, "roboledger") as session:
try:
reporting_style_id = load_primary_reporting_style(session)
except LookupError:
reporting_style_id = load_entity_reporting_style(
session, resolve_entity_id(session, entity_id)
)
except NoEntityError:
reporting_style_id = None
rows = reads_taxonomies.suggest_mapping_candidates(
session,
trait=classification,
reporting_style_id=reporting_style_id,
)
except EntityNotInGraphError as exc:
raise strawberry.exceptions.StrawberryGraphQLError(str(exc)) from exc
except (ValueError, ProgrammingError):
_raise_ledger_not_initialized()
return [Element.from_pydantic(r) for r in rows]
Expand Down Expand Up @@ -960,11 +970,20 @@ def structures(
return StructureList.from_pydantic(response)

@strawberry.field
def mappings(self, info: Info[GraphQLContext, None]) -> StructureList | None:
"""List all active mapping structures."""
def mappings(
self, info: Info[GraphQLContext, None], entity_id: str | None = None
) -> StructureList | None:
"""List the active mapping structures, each naming the entity whose chart
it maps from.

Args:
entity_id: Keep only this entity's mappings. Omit for every entity's.
"""
try:
with _open_session(info, "roboledger") as session:
response = reads_taxonomies.list_mappings(session)
response = reads_taxonomies.list_mappings(session, entity_id)
except EntityNotInGraphError as exc:
raise strawberry.exceptions.StrawberryGraphQLError(str(exc)) from exc
except (ValueError, ProgrammingError):
_raise_ledger_not_initialized()
return StructureList.from_pydantic(response)
Expand Down Expand Up @@ -1327,7 +1346,7 @@ def statement(
graph_id = require_graph_id(info)
try:
with _open_session(info, "roboledger") as session:
# get_statement resolves the primary entity's Reporting Style from
# get_statement resolves the report entity's Reporting Style from
# this same session — no hop to the platform DB.
response = reads_reports.get_statement(
session,
Expand Down
8 changes: 8 additions & 0 deletions robosystems/graphql/types/information_block.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,13 @@ class InformationBlock:
taxonomy_id: str | None
taxonomy_name: str | None
disclosure_id: str | None
entity_id: str | None = strawberry.field(
description=(
"The entity whose books the envelope reads: a schedule, reconciliation "
"or forecast reads its owner's; a shared block (statements, metrics) "
"the entity asked for, else the group parent."
)
)

information_model: InformationModel
artifact: Artifact
Expand Down Expand Up @@ -259,6 +266,7 @@ def from_pydantic(cls, envelope: PydanticInformationBlock) -> InformationBlock:
taxonomy_id=envelope.taxonomy_id,
taxonomy_name=envelope.taxonomy_name,
disclosure_id=envelope.disclosure_id,
entity_id=envelope.entity_id,
information_model=InformationModel.from_pydantic(envelope.information_model),
artifact=Artifact.from_pydantic(envelope.artifact),
elements=[InformationBlockElement.from_pydantic(e) for e in envelope.elements],
Expand Down
20 changes: 20 additions & 0 deletions robosystems/middleware/mcp/tools/information_block_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
from robosystems.operations.information_block import (
list_information_blocks as ops_list_information_blocks,
)
from robosystems.operations.roboledger.entity_scope import EntityNotInGraphError

from ._errors import database_failure
from .constants import ENTITY_ID_ARGUMENT

# ────────────────────────────────────────────────────────────────────────────
# get-information-block
Expand Down Expand Up @@ -54,6 +56,10 @@ def get_tool_definition(self) -> dict[str, Any]:
seam-adjacent columns — the last N actual columns and the first N
forecast columns. Omitted = unbounded. Prefer a window on deep-history
tenants: an unbounded series envelope grows with the ledger's age
- entity_id (optional): Whose books a block shared by the group reads
(statements, metrics, disclosures). Omitted = the scenario's entity, else
the group parent. A schedule, reconciliation or forecast always reads its
own entity's; the envelope's entity_id names the entity it read

**RETURNS:**
A typed envelope with:
Expand Down Expand Up @@ -109,6 +115,7 @@ def get_tool_definition(self) -> dict[str, Any]:
"(nearest the seam). Omitted = the full horizon."
),
},
"entity_id": ENTITY_ID_ARGUMENT,
},
"required": ["id"],
},
Expand All @@ -134,13 +141,17 @@ def _execute_sync(self, arguments: dict[str, Any]) -> Any:
series=series,
series_history=int(series_history) if series_history is not None else None,
series_forecast=int(series_forecast) if series_forecast is not None else None,
entity_id=arguments.get("entity_id"),
library_sentinel=(graph_id == LIBRARY_GRAPH_ID),
)
if envelope is None:
return {
"error": "not_found",
"message": f"Information Block not found: {block_id}",
}
return envelope.model_dump(mode="json")
except EntityNotInGraphError as exc:
return {"error": "entity_not_found", "message": str(exc)}
except SQLAlchemyError as exc:
return database_failure("get-information-block", exc)
except Exception as exc:
Expand Down Expand Up @@ -177,6 +188,10 @@ def get_tool_definition(self) -> dict[str, Any]:
- scenario_id (optional): A forecast block's structure id — each
envelope binds that scenario's FactSet slice instead of actuals
(list blocks with block_type='forecast' to discover scenarios)
- entity_id (optional): List the blocks shared by the group plus this
entity's own schedules, reconciliations and forecasts, never another
entity's; shared blocks read its books. Omitted = the scenario's entity,
else the group parent
- include_atoms (optional, default false): When false, returns a lean
summary per block (id, type, name, display_name, category, taxonomy_id,
taxonomy_name, disclosure_id, element_count, fact_count, rule_count).
Expand Down Expand Up @@ -234,6 +249,7 @@ def get_tool_definition(self) -> dict[str, Any]:
"scenario's FactSet slice instead of actuals."
),
},
"entity_id": ENTITY_ID_ARGUMENT,
},
"required": [],
},
Expand Down Expand Up @@ -274,6 +290,7 @@ def _execute_sync(self, arguments: dict[str, Any]) -> Any:
offset=offset,
library_sentinel=(graph_id == LIBRARY_GRAPH_ID),
scenario_id=scenario_id,
entity_id=arguments.get("entity_id"),
)
if include_atoms:
blocks = [e.model_dump(mode="json") for e in envelopes]
Expand All @@ -286,6 +303,8 @@ def _execute_sync(self, arguments: dict[str, Any]) -> Any:
"block_count": len(blocks),
"blocks": blocks,
}
except EntityNotInGraphError as exc:
return {"error": "entity_not_found", "message": str(exc)}
except ValueError as exc:
# Unknown block_type.
return {"error": "invalid_arguments", "message": str(exc)}
Expand All @@ -307,6 +326,7 @@ def _summarize_information_block(envelope) -> dict[str, Any]:
"taxonomy_id": envelope.taxonomy_id,
"taxonomy_name": envelope.taxonomy_name,
"disclosure_id": envelope.disclosure_id,
"entity_id": envelope.entity_id,
"element_count": len(envelope.elements or []),
"connection_count": len(envelope.connections or []),
"fact_count": len(envelope.facts or []),
Expand Down
2 changes: 1 addition & 1 deletion robosystems/middleware/mcp/tools/text_block_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def get_tool_definition(self) -> dict[str, Any]:
},
"entity_id": {
"type": "string",
"description": "Optional entity id; defaults to the primary entity",
"description": "Optional entity id; omit for the group parent",
},
},
"required": ["document_id", "structure_id", "period_start", "period_end"],
Expand Down
5 changes: 1 addition & 4 deletions robosystems/models/api/extensions/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,10 +308,7 @@ class ChangeReportingStyleRequest(BaseModel):
)
entity_id: str | None = Field(
None,
description=(
"Target entity. Omit to target the graph's primary "
"(earliest-created) entity — the single-entity default."
),
description=("The entity whose style to change, by id. Omit for the group parent."),
)

model_config = ConfigDict(
Expand Down
4 changes: 2 additions & 2 deletions robosystems/models/api/extensions/forecasts.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,8 @@ class CreateForecastRequest(BaseModel):
entity_id: str | None = Field(
None,
description=(
"Entity the scenario belongs to. Defaults to the graph's "
"earliest-created entity (single-entity convention)."
"The entity the scenario belongs to, by id: its history and its "
"computed months are that entity's books. Omit for the group parent."
),
)

Expand Down
8 changes: 8 additions & 0 deletions robosystems/models/api/extensions/schedules.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ class ScheduleMetadataRequest(BaseModel):

class CreateScheduleRequest(BaseModel):
name: str = Field(..., description="Schedule name")
entity_id: str | None = Field(
None,
description=(
"The entity whose books the schedule belongs to, by id. Omit for the "
"group parent. Its facts, obligations and closing entries land in "
"that entity's books, against that entity's own accounts."
),
)
taxonomy_id: str | None = Field(
None, description="Taxonomy ID (auto-creates if omitted)"
)
Expand Down
7 changes: 7 additions & 0 deletions robosystems/models/api/extensions/taxonomies.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ class StructureResponse(BaseModel):
"into (e.g. `rs-gaap`). A chart holds one mapping per framework."
),
)
entity_id: str | None = Field(
None,
description=(
"`coa_mapping` only, in the mappings list: the entity whose chart the "
"mapping maps from, so whose reports it can produce."
),
)


class StructureListResponse(BaseModel):
Expand Down
Loading
Loading